Analytical SQL · Window Functions: Running Totals and Comparisons

Running Totals, LAG/LEAD and Frames

Cumulative sums, moving averages over a window frame, change versus the previous period, and each row's share of its group.

  • 8 min read
  • 12 practice questions
  • 3 graded problems
On this page
  1. Description
  2. Running totals and the frame
  3. LAG and LEAD
  4. Moving averages: ROWS or RANGE
  5. FIRST_VALUE, LAST_VALUE and the default frame
  6. Shares of a total
  7. Frame cheat sheet
  8. Where it breaks
  9. Tradeoffs
  10. Check yourself
  11. Key takeaways
  12. Quick revise
  13. Practice

Description

The previous lesson used windows to rank rows. This one uses them to look along the rows: a running balance per account, the change from last month, a 7-day moving average. These are everyday questions on any dashboard that tracks money, traffic or sales over time.

All three are short queries, and all three hide a bug that tends to survive testing:

  • A running balance shows the same number on two payments made in the same second.
  • "Change versus last month" compares March with January whenever February had no sales.
  • A "7-day average" quietly becomes a 7-row average, which spans two weeks when some days are empty.

Each one comes down to a single idea, the window frame - which rows around the current one a window function actually looks at. Step by step:

  1. Running totals, and the frame you get by default.
  2. LAG and LEAD - peeking at the previous and next row.
  3. Moving averages counted in rows versus in days.
  4. FIRST_VALUE and LAST_VALUE, and why LAST_VALUE surprises everyone.
  5. Each row's share of a total.

Examples use five payments (paid_on, amount). Two were paid on March 2, and there was nothing on March 3. Every result was produced by PostgreSQL 18.

Running totals and the frame

With an ORDER BY inside OVER, an aggregate becomes cumulative. But the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE includes the current row's peers - every row with the same ORDER BY value:

SELECT id, paid_on, amount,
       SUM(amount) OVER (ORDER BY paid_on) AS default_frame,
       SUM(amount) OVER (ORDER BY paid_on, id
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame
FROM payments
ORDER BY paid_on, id;
idpaid_onamountdefault_framerows_frame
12026-03-01100.00100.00100.00
22026-03-0250.00175.00150.00
32026-03-0225.00175.00175.00
42026-03-0480.00255.00255.00
52026-03-0520.00275.00275.00

Payments 2 and 3 share a date, so the default frame gives both the total after both of them. For a per-transaction running balance you want one row at a time: make the order unique (ORDER BY paid_on, id) and say ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. The peer behavior is right when you want a total as of the end of each day.

Frames have three modes: ROWS counts physical rows, RANGE compares ORDER BY values (including an offset like INTERVAL '6 days'), and GROUPS counts peer groups. Without an ORDER BY in OVER, the frame is the whole partition - SUM(x) OVER () is the grand total on every row.

LAG and LEAD

LAG(x) returns x from the previous row of the window's order, LEAD(x) from the next. LAG(x, n, default) looks n rows back and returns default instead of NULL when there's no such row. Aggregate to one row per period first (here with a WITH query, the next module's subject), then compare:

WITH daily AS (
    SELECT paid_on, SUM(amount) AS revenue FROM payments GROUP BY paid_on
)
SELECT paid_on, revenue,
       LAG(revenue) OVER (ORDER BY paid_on)           AS previous,
       revenue - LAG(revenue) OVER (ORDER BY paid_on) AS change
FROM daily
ORDER BY paid_on;
paid_onrevenuepreviouschange
2026-03-01100.00NULLNULL
2026-03-0275.00100.00-25.00
2026-03-0480.0075.005.00
2026-03-0520.0080.00-60.00

Two things to notice. The first row has no previous one, so its change is NULL - usually what a report wants, since "change from nothing" is undefined. And March 4's "previous" is March 2: LAG steps back one row, not one day. When the calendar has gaps, either fill them first (generate_series, in the time-series module) or check that the previous row really is the previous period: CASE WHEN LAG(day) OVER w = day - 1 THEN LAG(revenue) OVER w END.

A percentage change divides by the previous value - guard it: 100.0 * (revenue - prev) / NULLIF(prev, 0) returns NULL instead of a division-by-zero error.

Moving averages: ROWS or RANGE

WITH daily AS (
    SELECT paid_on, SUM(amount) AS revenue FROM payments GROUP BY paid_on
)
SELECT paid_on, revenue,
       ROUND(AVG(revenue) OVER (ORDER BY paid_on
             ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS avg_3_rows,
       ROUND(AVG(revenue) OVER (ORDER BY paid_on
             RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW), 2) AS avg_3_days
FROM daily
ORDER BY paid_on;
paid_onrevenueavg_3_rowsavg_3_days
2026-03-01100.00100.00100.00
2026-03-0275.0087.5087.50
2026-03-0480.0085.0077.50
2026-03-0520.0058.3350.00
Three frames for the March 4 row of the daily revenue. ROWS 2 PRECEDING takes the two rows before it, which reach back to March 1 because March 3 has no row; RANGE INTERVAL '2 days' PRECEDING takes only March 2 to 4.

On March 4, "the last 3 rows" reaches back to March 1 (there's no March 3 row), while RANGE ... INTERVAL '2 days' PRECEDING covers only March 2-4. A "7-day average" is RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW - or ROWS BETWEEN 6 PRECEDING AND CURRENT ROW over a series with one row for every day, empty days included. Note both give partial averages at the start, where fewer days exist; filter those rows out if a full window is required.

FIRST_VALUE, LAST_VALUE and the default frame

The default frame stops at the current row, which makes LAST_VALUE useless without a frame clause:

SELECT id, amount,
       LAST_VALUE(amount) OVER (ORDER BY id) AS last_default,
       LAST_VALUE(amount) OVER (ORDER BY id
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_whole
FROM payments
ORDER BY id;
idamountlast_defaultlast_whole
1100.00100.0020.00
250.0050.0020.00
325.0025.0020.00
480.0080.0020.00
520.0020.0020.00

With the default frame, the "last" row is always the current one. Extend the frame to UNBOUNDED FOLLOWING - or flip the order and use FIRST_VALUE(...) OVER (ORDER BY id DESC).

Shares of a total

SUM(x) OVER () puts the grand total on every row (or OVER (PARTITION BY region) the region's total), so each row can compute its share:

WITH daily AS (
    SELECT paid_on, SUM(amount) AS revenue FROM payments GROUP BY paid_on
)
SELECT paid_on, revenue,
       ROUND(100.0 * revenue / SUM(revenue) OVER (), 1) AS pct_of_total
FROM daily
ORDER BY paid_on;
paid_onrevenuepct_of_total
2026-03-01100.0036.4
2026-03-0275.0027.3
2026-03-0480.0029.1
2026-03-0520.007.3

Windows can wrap aggregates directly, too: in a grouped query, SUM(SUM(amount)) OVER (ORDER BY paid_on) is the running total of the daily sums - the inner SUM is the GROUP BY aggregate, the outer one the window.

Frame cheat sheet

You wantFrame
Running total, one row at a timeORDER BY ts, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Running total as of the end of each day (peers together)ORDER BY day (the default RANGE frame)
Last N rowsROWS BETWEEN N-1 PRECEDING AND CURRENT ROW
Last N days, whatever rows existRANGE BETWEEN INTERVAL 'N-1 days' PRECEDING AND CURRENT ROW
The whole partition (totals, shares, LAST_VALUE)No ORDER BY, or ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
The previous or next row's valueLAG(x) / LEAD(x) - they ignore the frame
Everyone except the current row ("average of my peers")... EXCLUDE CURRENT ROW

Where it breaks

  • Running totals with ties in the ORDER BY - peers share a total. Add a unique tiebreaker and a ROWS frame.
  • LAG across missing periods - compares with the last row that exists, not the previous day or month.
  • A "7-day" average with ROWS 6 PRECEDING over data with gaps - it spans more than seven days.
  • LAST_VALUE with the default frame - returns the current row.
  • Percentage change with a zero or missing previous value - division by zero, or NULL; decide which and use NULLIF.
  • PARTITION BY forgotten - one account's running balance continues from another's.

Tradeoffs

  • Window functions vs a self-join for "previous row": LAG reads each row once; a self-join on prev.day = cur.day - 1 handles gaps naturally (a missing day simply doesn't match) but costs a join.
  • RANGE with an interval vs filling gaps first: the interval frame is concise; a generated calendar (next modules) also produces rows for empty days, which a report usually needs anyway.
  • Computing in SQL vs in the application: a running balance over millions of rows is one ordered scan in the database; pulling rows out to sum them in code moves all the data across the network.

Going further. The deep dive (next tab) covers how a moving frame is computed - why SUM over a sliding window is cheap and MAX isn't, what EXCLUDE and GROUPS do, why there's no COUNT(DISTINCT) window, when a frame spills to disk, and how ledgers keep running balances at scale.

Check yourself

  1. Two deposits of $50 were posted at the same instant. Why does SUM(amount) OVER (PARTITION BY account ORDER BY posted_at) show the same balance on both, and how do you fix it?
Show answer

With ORDER BY the default frame is RANGE ... CURRENT ROW, which includes every peer - both deposits share posted_at, so both get the balance after both. Order by a unique key (posted_at, id) and use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

  1. February had no orders. What does LAG(revenue) OVER (ORDER BY month) return for March?
Show answer

January's revenue - LAG returns the previous row. Fill missing months first, or only use the lagged value when the previous row's month is exactly one month earlier.

  1. Why does LAST_VALUE(price) OVER (ORDER BY day) return each row's own price?
Show answer

The default frame ends at the current row, so the current row is the last row of its frame. Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, or FIRST_VALUE with the order reversed.

Key takeaways

  • With ORDER BY in OVER, aggregates are cumulative; the default frame is RANGE ... CURRENT ROW, which includes peers.
  • For a per-row running total, order by a unique key and use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
  • LAG/LEAD step by rows, not by calendar periods - mind the gaps; guard divisions with NULLIF.
  • Moving averages: ROWS n PRECEDING counts rows, RANGE INTERVAL ... PRECEDING counts time.
  • LAST_VALUE needs a frame that reaches the end; SUM(x) OVER () gives the total for shares.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the frame

You wantWrite
Running total, one row at a timeSUM(x) OVER (ORDER BY ts, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Running total as of the end of each daySUM(x) OVER (ORDER BY day) (the default RANGE frame takes peers together)
The last N rowsROWS BETWEEN N-1 PRECEDING AND CURRENT ROW
The last N days, whatever rows existRANGE BETWEEN INTERVAL 'N-1 days' PRECEDING AND CURRENT ROW
The whole partition (totals, shares, LAST_VALUE)No ORDER BY, or ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
The previous / next row's valueLAG(x) / LEAD(x) - they ignore the frame
Everyone except me... EXCLUDE CURRENT ROW

Frame modes: ROWS counts rows, RANGE compares ORDER BY values (offsets like INTERVAL '6 days'), GROUPS counts peer groups.

Snippets worth knowing by heart

-- per-transaction running balance per account
SUM(amount) OVER (PARTITION BY account ORDER BY posted_at, id
                  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- change versus the previous period, safe percentage
revenue - LAG(revenue) OVER (ORDER BY month)
100.0 * (revenue - prev) / NULLIF(prev, 0)

-- LAG only when the previous row really is yesterday
CASE WHEN LAG(day) OVER w = day - 1 THEN LAG(revenue) OVER w END

-- 7-day moving average by calendar
AVG(revenue) OVER (ORDER BY day RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW)

-- share of the total, and running total of grouped sums
100.0 * revenue / SUM(revenue) OVER ()
SUM(SUM(amount)) OVER (ORDER BY paid_on)    -- in a GROUP BY paid_on query

The traps

  • The default frame with ORDER BY is RANGE ... CURRENT ROW, which includes peers: two payments at the same time share one running total. Add a unique tiebreaker and ROWS.
  • LAG steps back one row, not one day - across a gap it compares with whatever row exists.
  • A "7-day" average with ROWS 6 PRECEDING spans more than 7 days when days are missing.
  • LAST_VALUE with the default frame returns the current row.
  • Forgetting PARTITION BY - one account's balance continues from another's.
  • Dividing by the previous value - zero or NULL; decide which and use NULLIF.
  • Moving windows at the start of the data are partial - filter them out if a full window is required.

Staff-level points, one line each

  • Growing frames (running totals) add one row at a time: O(1) per row. Sliding frames need an inverse transition function to remove the row that leaves.
  • sum(numeric), sum(bigint), avg(numeric), count have one (constant cost per row); max, min and sum(double precision) don't, so each row recomputes its whole frame.
  • sum(float8) has no inverse because (a + b) - b isn't always a - an incremental float sum would drift.
  • A 30-day moving MAX over per-second data: pre-aggregate to daily maxima, then slide over 30 rows.
  • COUNT(DISTINCT ...) OVER (...) doesn't exist ("DISTINCT is not implemented for window functions"): self-join on the date range, merge per-day sketches, or count a deduplicated (day, user) table.
  • Forward-looking frames (UNBOUNDED FOLLOWING, LEAD) buffer the partition and can spill (Storage: Disk); a running total keeps only the aggregate state. One "whale" partition makes the whole query slow.
  • RANGE INTERVAL '1 day' on timestamptz uses the session time zone: across a DST change "1 day" is 23 or 25 hours. Use INTERVAL '24 hours' when you mean hours.
  • GROUPS and EXCLUDE exist in PostgreSQL 11+, SQLite and DuckDB, not MySQL or SQL Server; SQL Server RANGE frames take no offsets.
  • Live balances: store the balance with each ledger entry under SELECT ... FOR UPDATE on the account (writes serialize per account), or checkpoints plus today's deltas; append-only corrections; always numeric or integer cents.

Say it in an interview

  • "Two payments share a timestamp, so I'll order by (posted_at, id) with a ROWS frame."
  • "LAG skips over the empty month - I'll generate the calendar first or check the previous period explicitly."
  • "A moving MAX can't slide incrementally, so I'd pre-aggregate per day."
  • "Rolling distinct users can't be a window function; I'd merge daily sketches."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Running Totals, LAG/LEAD and Frames free

Running Balance per Account

MediumProSQL problem

The banking app's statement view shows, next to every transaction, the account's balance right after it. Deposits are positive amounts, withdrawals negative; every account starts at 0.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Month-over-Month Revenue Change

HardProSQL problem

The monthly business review compares each month's revenue with the previous calendar month. Revenue is the total of orders that weren't cancelled (status <> 'cancelled'), bucketed by the UTC month of ordered_at.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Customers Making 80% of Revenue

HardProSQL problem

Account management wants the smallest group of top customers that together bring in 80% of revenue (a Pareto list), so each gets a named account manager.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Running Totals, LAG/LEAD and Frames free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime