open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 04 · 05

Running totals, moving averages, LAG/LEAD

Cumulative SUM, moving averages with ROWS BETWEEN n PRECEDING, and LAG/LEAD for period-over-period deltas — plus the classic LAST_VALUE trap, where the default frame ends at CURRENT ROW so LAST_VALUE returns the current row, not the partition's last.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A “biggest deal so far this quarter” column on the sales dashboard shows the wrong number on every row except the last. The query used LAST_VALUE(amount) OVER (ORDER BY sale_date). It reads like English — “the last value” — but it returns the current row’s amount, not the quarter’s final one, because the default frame ends at CURRENT ROW. LAST_VALUE is the single most reported window-function bug, and the fix is one frame clause. This lesson is the accumulation toolkit: running totals, moving averages, row-to-row deltas, and that trap.

Running totals and moving averages

A running total is SUM over a frame that grows from the partition’s start to the current row. From the frames lesson, always write ROWS for a sequential per-row total:

SELECT region, sale_date, amount,
       SUM(amount) OVER (
         PARTITION BY region ORDER BY sale_date
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM sales;

A moving average is the same idea with a bounded frame — average over the current row and the few before it:

-- 3-row trailing moving average of daily amount
SELECT sale_date, amount,
       AVG(amount) OVER (
         ORDER BY sale_date
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS moving_avg_3
FROM sales;

The only change from a running total is the start bound: 2 PRECEDING instead of UNBOUNDED PRECEDING. Near the start of the partition the frame simply holds fewer rows (1, then 2, then the full 3) — correct edge behavior, not a bug.

LAG and LEAD: row-to-row deltas

LAG(col, n) reads a column from n rows before the current one in the window order; LEAD(col, n) reads n rows after. They are how you compute period-over-period change without a self-join:

-- month-over-month change in revenue
SELECT month, revenue,
       revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
             / NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS mom_pct
FROM monthly_revenue;

LAG(revenue) on the first row is NULL (there is no previous row) — give it a default with LAG(revenue, 1, 0) if you want 0 instead, and guard division with NULLIF so a zero previous value doesn’t divide-by-zero. LAG/LEAD ignore the frame entirely; they navigate by row offset within the ordered partition.

FIRST_VALUE, LAST_VALUE, and the trap

FIRST_VALUE(col) returns the column from the first row of the frame, LAST_VALUE(col) from the last. The trap: with an ORDER BY in the window, the default frame is RANGE UNBOUNDED PRECEDING AND CURRENT ROW, so the frame’s last row is the current row. LAST_VALUE therefore returns the current row’s value, not the partition’s final value — which almost no one wants.

-- WRONG: returns the current row's amount, because the frame ends at CURRENT ROW
LAST_VALUE(amount) OVER (PARTITION BY region ORDER BY sale_date)

-- RIGHT: extend the frame to the end of the partition
LAST_VALUE(amount) OVER (
  PARTITION BY region ORDER BY sale_date
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

FIRST_VALUE usually looks correct by accident: the frame starts at UNBOUNDED PRECEDING, which is genuinely the first row, so the default frame already covers it. It’s LAST_VALUE that bites — its target lives at the far end of the partition, beyond the default frame’s CURRENT ROW ceiling. Fix it by extending the frame to UNBOUNDED FOLLOWING, or sidestep it with MAX/MIN over the full partition (no ORDER BY, so the frame is the whole partition) when you want an extreme rather than the literal last-by-order row.

Common mistake

The reason LAST_VALUE confuses people is that the function name describes intent while the frame decides reality. Two production-safe habits: (1) whenever you write LAST_VALUE with an ORDER BY, immediately add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, or you will silently get the current row; (2) if you actually want “the value associated with the max/min of some column,” prefer FIRST_VALUE(col) OVER (... ORDER BY key DESC) — order so the wanted row is first, where the default frame already reaches — or use a DISTINCT ON / MAX form. Same numbers-don’t-lie discipline as the frames lesson: don’t trust the function name, verify the frame.

Quiz

LAST_VALUE(amount) OVER (PARTITION BY region ORDER BY sale_date) returns the wrong value on most rows. Why?

Quiz

You want month-over-month revenue change. Which expression is correct?

Complete the analogy

Fill in the blank: to make LAST_VALUE return the partition's true final row instead of the current row, extend the frame's end bound to UNBOUNDED _______.

Recall before you leave
  1. 01
    What's the difference in frame between a running total and a 3-row moving average?
  2. 02
    Why does LAST_VALUE often return the wrong value, and how do you fix it?
  3. 03
    How do you compute month-over-month change, and what edge cases need guarding?
Recap

This is the accumulation toolkit. A running total is SUM over ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW; a moving average is the same with a bounded start like 2 PRECEDING; both lean on the explicit ROWS frame from the previous lesson so ties don’t distort them. LAG and LEAD step backward and forward by row offset to compute period-over-period deltas without a self-join — remember the first/last row yields NULL, and guard percent math with NULLIF. The signature trap is LAST_VALUE: its name promises the partition’s final row, but with an ORDER BY the default frame ends at CURRENT ROW, so it returns the current row instead; extend the frame to UNBOUNDED FOLLOWING (or reorder and use FIRST_VALUE, or use MAX/MIN over the full partition). FIRST_VALUE looks fine only because the default frame already starts at the partition’s first row. Now when you see LAST_VALUE in a query and the numbers look right in dev, add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING anyway — it will be wrong in production the first day a tied timestamp appears. The next lesson assembles these primitives into real patterns — gaps-and-islands, sessionization, deduplication — and the performance cost of stacking multiple windows.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.