Computing period-over-period change with lag and lead

Use func.lag(value, 1).over(partition_by=..., order_by=...) to bring the previous period's value onto each row, so growth is computed, filtered and sorted in SQL — and decide explicitly what the first row of each partition compares against, because lag returns NULL there. This guide belongs to window functions and analytical queries.

Quick Answer

Comparing a row with the one before it does not need a Python loop, and doing it in SQL means the result can be filtered and sorted.

Reach the previous row in SQL Left: the rows are loaded in order and Python walks them keeping the previous value, so the comparison cannot be filtered or sorted in the database and every row has to be fetched. Right: lag over the ordered window gives each row the previous period value as a column, so growth can be computed, filtered and sorted in SQL. walk the rows in Python previous = None; for row in rows: every row must be fetched cannot filter on growth cannot sort by it either lag(value) OVER (ORDER BY period) the previous value as a column growth computed in SQL filterable and sortable LIMIT applies afterwards lag and lead are the two window functions that turn a sequence into a comparison.

Before — the comparison done after loading every row:

from sqlalchemy import func, select

from shop.models import Order

rows = (await session.execute(
    select(
        func.date_trunc("month", Order.placed_at).label("month"),
        func.sum(Order.total_cents).label("revenue"),
    )
    .group_by(func.date_trunc("month", Order.placed_at))
    .order_by(func.date_trunc("month", Order.placed_at))
)).all()

growth = []
previous = None
for month, revenue in rows:                 # every month must be fetched
    growth.append((month, revenue, None if previous is None else revenue - previous))
    previous = revenue

After — lag brings the previous value onto the row:

from sqlalchemy import func, select

from shop.models import Order

monthly = (
    select(
        func.date_trunc("month", Order.placed_at).label("month"),
        func.sum(Order.total_cents).label("revenue_cents"),
    )
    .where(Order.status != "cancelled")
    .group_by(func.date_trunc("month", Order.placed_at))
    .cte("monthly")
)

previous = func.lag(monthly.c.revenue_cents, 1).over(order_by=monthly.c.month)

stmt = (
    select(
        monthly.c.month,
        monthly.c.revenue_cents,
        previous.label("previous_cents"),
        (monthly.c.revenue_cents - previous).label("change_cents"),
        (
            100.0 * (monthly.c.revenue_cents - previous)
            / func.nullif(previous, 0)
        ).label("change_pct"),
    )
    .order_by(monthly.c.month)
)

func.nullif(previous, 0) is the division guard: a previous value of zero yields NULL rather than a division error, and the first month — where lag is NULL — produces NULL throughout rather than a wrong number.

Execution Context & Async Workflow Integration

lag and lead are window functions that read another row of the same window. They do not aggregate; they navigate. lag(value, offset, default) reads the row offset positions earlier in the window's order, and lead reads forward. Both are computed after WHERE and GROUP BY, which is why a period-over-period query usually aggregates in one stage and navigates in the next — the CTE shape from using non-recursive CTEs to structure reporting queries.

What lag sees Four steps. The window is partitioned by product and ordered by month, so each product has its own ordered sequence. For each row, lag of revenue with an offset of one reads the value from the row before it in that partition. The first row of each partition has no previous row, so lag returns NULL unless a default is supplied. Growth is then the current value minus the lagged one, with the NULL case handled explicitly. PARTITION BY product_id ORDER BY month one sequence per product the frame is per partition lag(revenue, 1) OVER (...) the previous month value as a column on this row the first row of each partition no previous row lag returns NULL revenue − previous growth, with NULL handled A default third argument — lag(revenue, 1, 0) — replaces the NULL when zero is the right baseline.

partition_by restarts the sequence. Without it, one product's last month is followed by the next product's first, and the growth figure at that boundary is nonsense. With partition_by=monthly.c.product_id, each product gets its own ordered sequence and its own NULL at the start:

previous = func.lag(monthly.c.revenue_cents, 1).over(
    partition_by=monthly.c.product_id,
    order_by=monthly.c.month,
)

The third argument replaces the NULL for rows with no predecessor: func.lag(col, 1, 0) treats the period before the first as zero. Which is right depends on the question. For revenue growth, zero is often defensible; for a temperature reading, it is not, and NULL propagating through the calculation is the honest answer.

The subtle failure is that lag reads the previous row, not the previous month. If a product sold nothing in March, there is no March row, and April's lag reaches February — silently reporting two months of growth as one. Making the sequence dense before navigating it is the fix, and it needs a generated calendar joined to the aggregate, which is the subject of bucketing time series and filling gaps with generate_series.

Under async none of this is special: the whole query is built synchronously and executed with one await session.execute(stmt), returning rows rather than entities. That makes analytical queries some of the most comfortable code to write against an AsyncSession — there are no relationships to load and nothing that can lazy-load unexpectedly.

One performance note. Window functions require the rows to be sorted in the window's order, so an index matching (partition_by..., order_by...) lets PostgreSQL skip the sort entirely. On an aggregate stage the rows are usually few enough that it does not matter; over raw rows on a large table it matters a great deal, and reading EXPLAIN output for a SQLAlchemy query shows how to tell whether the sort was skipped.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
ProgrammingError: window functions are not allowed in WHEREFiltering on the lagged value in the same query that computes it.Compute in a CTE or subquery, filter outside.
ProgrammingError: division by zeroThe previous value was zero.func.nullif(previous, 0) as the divisor.
The first row shows a huge changeNULL treated as zero somewhere downstream.Supply a default deliberately, or exclude the first row.
Growth is wrong at the boundary between groupsNo partition_by, so the sequence ran across groups.partition_by the grouping column.
last_value returns the current row's valueThe default frame ends at the current row.An explicit full frame: rows=(None, None).
A month with no data is skipped and the next month's growth is wronglag reads the previous row, not the previous calendar period.Generate the periods and left-join the aggregate.
The query is slow over a large tableThe window requires a sort the planner cannot skip.An index matching the partition and order columns.
Four ways to reach another row Four tiles. lag reads a row before the current one in the window order. lead reads a row after it. first_value and last_value read the extremes of the frame, which makes the frame definition matter. And nth_value reads a specific position, counting from the frame start. lag(col, n, default) n rows back previous period lead(col, n, default) n rows forward next scheduled date first_value / last_value the frame extremes frame definition matters nth_value(col, n) a specific position from the frame start last_value with the default frame returns the current row — the classic window-frame surprise.

The last_value surprise is worth seeing in full, because it looks like a bug and is specified behaviour. With an ORDER BY and no frame clause, the window frame is "from the start of the partition to the current row", so last_value is the current row:

from sqlalchemy import func, select

from shop.models import Reading

# Wrong: returns each row's own value.
latest_wrong = func.last_value(Reading.value).over(
    partition_by=Reading.sensor_id, order_by=Reading.taken_at
)

# Right: the frame covers the whole partition.
latest = func.last_value(Reading.value).over(
    partition_by=Reading.sensor_id,
    order_by=Reading.taken_at,
    rows=(None, None),          # UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

SQLAlchemy's rows= and range= parameters take a two-tuple where None means unbounded and 0 means the current row, so rows=(None, 0) is the default frame and rows=(-2, 0) is a three-row trailing window. lag and lead ignore the frame entirely — they always navigate by position in the window order — which is why they are more predictable than the value functions.

A trailing average is the most common use of an explicit frame, and it reads naturally once the tuple convention is clear:

rolling_3 = func.avg(monthly.c.revenue_cents).over(
    order_by=monthly.c.month, rows=(-2, 0)      # this month and the two before
).label("rolling_3_month_avg")

Advanced: Year-Over-Year, Gaps and Sessionisation

Once lag is available, three common analytical questions become single queries.

The default frame is not what you want Left: with an ORDER BY and no frame clause, the frame is from the start of the partition to the current row, so last_value returns the current row value rather than the partition maximum. Right: an explicit frame from unbounded preceding to unbounded following covers the whole partition, and last_value returns what the name suggests. no frame clause RANGE UNBOUNDED PRECEDING AND CURRENT ROW last_value = the current row looks broken, is documented an explicit full frame rows=(None, None) the whole partition last_value = the partition maximum first_value unaffected lag and lead ignore the frame; first_value, last_value and nth_value do not.

Year-over-year is lag with an offset of twelve over a monthly series — which is correct only if the series is dense, because the offset counts rows:

from sqlalchemy import func, select

year_ago = func.lag(monthly.c.revenue_cents, 12).over(
    partition_by=monthly.c.product_id, order_by=monthly.c.month
)

stmt = select(
    monthly.c.month,
    monthly.c.revenue_cents,
    year_ago.label("year_ago_cents"),
    (
        100.0 * (monthly.c.revenue_cents - year_ago) / func.nullif(year_ago, 0)
    ).label("yoy_pct"),
)

If any month can be missing, the offset silently reaches the wrong row, and the only robust version joins the aggregate to a generated month series first.

Gap detection uses lag on a timestamp to find intervals longer than expected — missing readings, a stalled feed, a subscription lapse:

from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import INTERVAL

from shop.models import Reading

previous_at = func.lag(Reading.taken_at).over(
    partition_by=Reading.sensor_id, order_by=Reading.taken_at
)

readings = select(
    Reading.sensor_id,
    Reading.taken_at,
    previous_at.label("previous_at"),
    (Reading.taken_at - previous_at).label("interval"),
).cte("readings")

gaps = select(readings).where(
    readings.c.interval > func.cast("00:15:00", INTERVAL)
)

Sessionisation — grouping events into sessions separated by inactivity — is the same idea with a cumulative sum: mark each row where the gap exceeds the threshold, then count the marks to give every row a session number:

from sqlalchemy import Integer, case, cast, func, select
from sqlalchemy.dialects.postgresql import INTERVAL

from shop.models import Event

previous_at = func.lag(Event.occurred_at).over(
    partition_by=Event.customer_id, order_by=Event.occurred_at
)

marked = select(
    Event.customer_id,
    Event.occurred_at,
    cast(
        case((Event.occurred_at - previous_at > func.cast("00:30:00", INTERVAL), 1), else_=0),
        Integer,
    ).label("is_new_session"),
).cte("marked")

sessions = select(
    marked.c.customer_id,
    marked.c.occurred_at,
    func.sum(marked.c.is_new_session).over(
        partition_by=marked.c.customer_id, order_by=marked.c.occurred_at
    ).label("session_number"),
)

A running sum over a zero-or-one column is the standard way to turn "where does a group start" into "which group is this row in", and it is worth recognising because the same shape solves streak detection, version numbering and gap-and-island problems. The running-total mechanics are covered in writing window functions for running totals in Python.

Testing an Analytical Query

Analytical queries are worth testing precisely because their output is plausible when wrong: a growth figure is a number either way, and nothing errors. Three test shapes catch the mistakes that matter.

Three decisions in a growth query Three decisions. What the first period compares against, since there is no previous row: NULL, zero, or excluded from the output. What a missing period means, because lag reads the previous row present, not the previous calendar month. And whether the percentage divides by zero, which needs an explicit guard. the first period has no predecessor lag returns NULL — choose a default, or filter the row out lag reads the previous ROW, not the previous month a month with no rows is skipped silently; generate a calendar first a percentage divides by the previous value NULLIF(previous, 0) so a zero baseline yields NULL rather than an error

A fixture with hand-computed expectations. Small, explicit data, and the expected numbers written out rather than computed by the same logic under test:

import datetime as dt

import pytest

from shop.reports import monthly_growth


@pytest.fixture
async def three_months(session, order_factory):
    for day, cents in [
        (dt.date(2026, 7, 4), 1_000),
        (dt.date(2026, 7, 18), 500),     # July: 1,500
        (dt.date(2026, 8, 2), 3_000),    # August: 3,000
        (dt.date(2026, 9, 9), 1_500),    # September: 1,500
    ]:
        await order_factory(placed_on=day, total_cents=cents)
    await session.commit()


@pytest.mark.asyncio
async def test_monthly_growth(session, three_months):
    rows = (await session.execute(monthly_growth())).mappings().all()

    assert [row["revenue_cents"] for row in rows] == [1_500, 3_000, 1_500]
    assert rows[0]["previous_cents"] is None          # no month before July
    assert rows[0]["change_pct"] is None
    assert rows[1]["change_cents"] == 1_500
    assert rows[1]["change_pct"] == pytest.approx(100.0)
    assert rows[2]["change_cents"] == -1_500
    assert rows[2]["change_pct"] == pytest.approx(-50.0)

The first assertion about None is the valuable one: it pins the decision about what the first period compares against, so a later change to a default is a deliberate test change rather than a silent shift in every report.

A boundary case for partitions. Two products, each with two months, and an assertion that neither product's first month borrowed the other product's last. Without partition_by, that test fails and nothing else does.

A zero-baseline case. One month with zero revenue followed by a month with some, asserting the percentage is NULL rather than an error. That is the nullif guard, and it is the kind of row that appears in production data months after launch.

Run all of it against PostgreSQL. Window frames, date_trunc, interval arithmetic and NULL ordering differ or do not exist on SQLite, so a passing test there says very little — the container fixture in running tests against a Postgres testcontainer is what makes these assertions meaningful.

Frequently Asked Questions

What is the difference between lag and lead?

lag reads a row earlier in the window order, lead reads a later one. Both take an offset and an optional default for rows with no neighbour at that distance.

Why is the first row of my growth query NULL?

Because lag has no previous row there. Supply a third argument as the default — func.lag(col, 1, 0) — or filter the row out, and decide which is honest for the metric.

Why does last_value return the current row?

The default window frame with an ORDER BY ends at the current row. Pass rows=(None, None) to cover the whole partition. lag and lead are unaffected by the frame.

Can I filter on a lagged value?

Not in the same query level — window functions are computed after WHERE. Put the window function in a CTE or subquery and filter in the enclosing query.