skip to content

Using LAG, how do you compute each month's percent change over the previous month?

level: middleimportance: should knowfreq 62%

answer

  1. Compare each row against its predecessor
  2. Same window used for delta and denominator
  3. Guard the denominator against zero
  4. Integer columns truncate the ratio
  5. Series identity belongs in PARTITION BY

basics

~10 s

Subtract LAG(revenue) OVER (PARTITION BY series ORDER BY month) from revenue and divide by that same LAG value wrapped in NULLIF(..., 0). Multiply by 100.0 so the division is not integer division.

solid answer

~50 s

The shape is `(revenue - LAG(revenue) OVER w) / LAG(revenue) OVER w`, with three guards. First, **partition** by whatever identifies the series (`region`, `product_id`) or the first month of one series will read the last month of another. Second, wrap the denominator in `NULLIF(LAG(revenue) OVER w, 0)` so a zero previous period yields NULL instead of a division-by-zero error. Third, force non-integer arithmetic — multiply by `100.0` or cast — because with integer columns the ratio truncates to 0. The first row of each partition is correctly NULL: there is no previous period, and defaulting it to 0 would either report infinite growth or divide by zero. If you need to filter on the computed change, put the window query in a CTE and filter the outer query, since window results are not visible to `WHERE`.

code

sql · 5 lines
sql
-- Wrong: no partitioning, integer division, unguarded denominator
SELECT region, month, revenue,
       100 * (revenue - LAG(revenue) OVER (ORDER BY month))
           / LAG(revenue) OVER (ORDER BY month) AS pct_change
FROM monthly_revenue;

go deeper

for a junior

Be able to write the subtraction form — revenue minus LAG(revenue) over an ordered window — and say why the earliest row comes back NULL.

for a middle

Explain each guard: PARTITION BY for series identity, NULLIF for the zero denominator, and non-integer arithmetic so the ratio does not truncate.

for a senior

Volunteer the gap assumption — LAG steps one row, not one month — and describe densifying against a calendar before the window runs, plus wrapping in a CTE to filter on the computed change.

for a principal

Frame it as a metric-definition problem: whether missing periods mean zero or unknown, and whether growth is undefined or infinite from a zero base, are decisions that must be settled once and applied consistently across every report.

## The canonical query ```sql SELECT region, month, revenue, revenue - LAG(revenue) OVER (PARTITION BY region ORDER BY month) AS delta, 100.0 * (revenue - LAG(revenue) OVER (PARTITION BY region ORDER BY month)) / NULLIF(LAG(revenue) OVER (PARTITION BY region ORDER BY month), 0) AS pct_change FROM monthly_revenue; ``` This is the highest-frequency practical use of `LAG` and appears constantly in analytics interviews as "show month-over-month growth". Every element of it is there for a reason. ## Why PARTITION BY The table almost always holds several interleaved series — one row per region per month, one per product per day. Without `PARTITION BY region`, the window is one long ordered list, and the earliest month of the second region reads the latest month of the first. The result is not an error; it is a plausible-looking wrong number, which is worse. Partition by exactly the columns that identify one series. ## Why NULLIF A previous period of zero makes the ratio undefined. Most engines raise a division-by-zero error rather than returning infinity, so one zero month aborts the whole query. `NULLIF(x, 0)` returns NULL when `x` is 0 and `x` otherwise; dividing by NULL yields NULL, which propagates harmlessly and reads as "not computable". This is the standard, portable guard. ## Why 100.0 rather than 100 If `revenue` is an integer type, `(a - b) / b` is integer division: a 40% rise computes as `40 / 100` = `0`. Multiplying by `100.0` first, or casting one operand to a decimal type, keeps the arithmetic exact-numeric or approximate rather than truncating. This bites people who test with a nicely rounded fixture and never see the truncation. ## Why the first row should stay NULL `LAG(revenue)` returns NULL for the first month of each partition, so `delta` and `pct_change` are NULL there. That is the correct answer — no prior period exists. The tempting `LAG(revenue, 1, 0)` makes `delta` equal the whole first month's revenue (reported as growth from nothing) and turns the denominator into 0. Leave the default as NULL and let the consumer decide how to render it. ## The gap trap `LAG` returns the previous **row**, not the previous **calendar month**. If a region has no row for March, April's `LAG` reaches back to February and the query silently labels a two-month change as month-over-month. Two honest responses: state the assumption that the series is dense, or join against a generated calendar of periods first so that missing periods exist as rows before the window runs. ## Filtering on the result Window functions are evaluated after `WHERE`, `GROUP BY` and `HAVING`, so `WHERE pct_change < -10` in the same select is invalid. Wrap it: ```sql WITH mom AS ( SELECT region, month, revenue, LAG(revenue) OVER (PARTITION BY region ORDER BY month) AS prev FROM monthly_revenue ) SELECT region, month, 100.0 * (revenue - prev) / NULLIF(prev, 0) AS pct_change FROM mom WHERE prev IS NOT NULL AND revenue < prev; ``` Computing `prev` once in the CTE also removes the repetition of the `LAG` expression, which is the readability win of this shape. ## Year-over-year and other offsets On a dense monthly series, `LAG(revenue, 12)` gives the same month a year earlier — again dependent on the series having no gaps. When the data is not dense, prefer a self-join on an explicit date arithmetic predicate, or densify first. On a daily series, `LAG(value, 7)` gives the same weekday a week earlier, which is often more meaningful than day-over-day for weekly-seasonal data. ## What interviewers listen for The query itself is table stakes; the differentiators are naming the partitioning column without prompting, guarding the denominator, keeping the first row NULL, and volunteering that `LAG` counts rows rather than calendar periods.

  • Your monthly table is missing some months for some regions. What does that do to the LAG-based month-over-month change?
    `LAG` returns the previous existing row, not the previous calendar month, so a March gap makes April's figure a February-to-April change labelled as month-over-month. Fix it by densifying: build the full set of period rows (a calendar table or generated series joined to the region list), left-join the facts onto it, and run the window over the dense result.
  • Why not just write LAG(revenue, 1, 0) and avoid NULLs entirely?
    Because the zero is not a real previous period. The delta then reports the first month's whole revenue as growth, and the percent-change denominator becomes zero, which either errors or must be special-cased anyway. NULL is the accurate encoding of "no comparison exists", and `NULLIF` on the denominator handles the genuine zero-revenue case separately.

saying these in an interview costs you the question

  • Forgets PARTITION BY and compares across different series
  • Divides without NULLIF and hits division by zero
  • Uses integer 100 and gets a truncated ratio
  • Filters on the window result in the same WHERE clause
  • Assumes LAG steps one calendar period rather than one row

context