Running Totals: Beginner
"Show Me the Running Balance"
Recognize that any question asking for cumulative, year-to-date, or rolling aggregates is a running total pattern.
- ▸"cumulative ... over time"
- ▸"running balance," "running total," "running count"
- ▸"year-to-date," "month-to-date," "life-to-date"
- ▸"7-day trailing," "30-day rolling"
- ▸"the value as of date X"
What 'running' means, and how to spot it
A running total is not just a sum. A regular SUM with a GROUP BY produces one row per group, with the total for that group. A running total produces one row per input row, with the total of every row that came before plus the current row. The output has the same number of rows as the input. Each row's value depends on the rows above it in some order. That ordering is the part the interviewer is checking you understand.
- Writes SELECT SUM(amount) FROM transactions GROUP BY account_id
- Returns one row per account with the lifetime total
- Has to be corrected when the interviewer asks for per-row output
- Loses time recovering from a misread of the question
- Says 'This is a running total, I will use SUM OVER'
- Names the partition (per account), the order (by date), and the frame
- Writes the canonical shape in twelve lines without hesitation
- Has working SQL on the page within ninety seconds
Why companies care
SUM OVER (ORDER BY ...) Basics
Write a correct running total using SUM() OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
The query you should be able to write from memory
Read the query out loud
- ▸PARTITION BY: where to restart the running total
- ▸ORDER BY: in what order to accumulate rows
- ▸ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: which rows to include
Walk through a small example
| account_id | txn_date | amount | running_balance |
|---|---|---|---|
| A | 2025-01-01 | 100 | 100 |
| A | 2025-01-02 | 50 | 150 |
| A | 2025-01-05 | 200 | 350 |
| B | 2025-01-01 | 300 | 300 |
| B | 2025-01-03 | -100 | 200 |
Why the frame clause is required
Read this sentence three times: ROWS, not RANGE. The default in SQL is RANGE. The right answer is almost always ROWS. The single most common bug in beginner running-total queries is forgetting the frame clause and getting RANGE by default. The bug ships, the dashboard shows duplicated values for same-day transactions, and the candidate cannot reproduce it in development because the test data has unique dates.
The variant: year-to-date instead of lifetime
What a Window Frame Actually Is
Explain why RANGE (the default) groups duplicate ORDER BY values and produces wrong running totals, while ROWS does not.
The bug, stated plainly
If you write SUM(amount) OVER (ORDER BY txn_date) without the frame clause, SQL defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE treats rows with the same ORDER BY value as a single peer group. If two transactions land on the same date, RANGE adds them both at once and assigns both rows the same cumulative value. ROWS would add them one at a time and assign each its own cumulative value. The default is RANGE. The right answer is ROWS.
- ▸Postgres: RANGE by default (documented)
- ▸SQL Server: RANGE by default (documented)
- ▸BigQuery: RANGE by default
- ▸Snowflake: RANGE by default
- ▸Always write ROWS explicitly
Concrete example of the bug
- Rows tied on the ORDER BY column share a single cumulative value
- Three transactions on the same day all get the same running total
- Looks reasonable in a quick spot check
- Wrong whenever multiple rows can share an ORDER BY value
- Every row gets its own cumulative value
- Ties on the ORDER BY column are broken by row position
- Matches what an analyst means by 'running total'
- Always write this explicitly; do not rely on the default
Why the default is the way it is
The Postgres documentation explicitly states the default is RANGE. So do the SQL Server, BigQuery, and Snowflake docs. Most candidates default to writing no frame at all because they have never read the docs carefully. Saying 'I always write ROWS explicitly because the default is RANGE, which collapses ties' is the move that proves you have actually read the spec or been bitten by the bug.
How to remember which one to use
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see 'running total per account' | "SUM with a GROUP BY." | "This is a running total. SUM OVER (PARTITION BY account_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). One row per input row, not one per group." |
| The interviewer asks 'what about ties on date' | "It just works." | "That's why the ROWS frame is required. RANGE (the default) treats date-ties as a peer group and assigns them the same cumulative value, which is silently wrong. ROWS gives each row its own cumulative value." |
| The interviewer asks for year-to-date | "I'd rewrite the query." | "One change: add EXTRACT(YEAR FROM txn_date) to the PARTITION BY. The frame and the ORDER BY stay the same. Same template handles MTD with DATE_TRUNC('month', txn_date)." |
| The interviewer asks for 7-day rolling | "I'd subquery." | "Same shape, change UNBOUNDED PRECEDING to 6 PRECEDING. The frame is inclusive on both sides; 6 PRECEDING through CURRENT ROW is seven rows." |
| The interviewer asks 'only positive transactions' | "WHERE amount > 0." | "WHERE drops the negative rows from the output; the consumer probably wants them present but excluded from the cumulative arithmetic. SUM(CASE WHEN amount > 0 THEN amount END) keeps every row and contributes only matching ones; SUM ignores NULL." |
Cumulative vs Grand Total
Extend running totals to 7-day rolling averages, monthly resets, and conditional cumulative sums.
7-day rolling sum
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is seven rows, not six. The frame is inclusive on both sides. Count carefully: 6 PRECEDING through CURRENT ROW means six rows before plus the current row, which is seven rows total.
Monthly reset
Conditional running totals
The pattern, restated
- ▸Frame clause for window size (lifetime, rolling N, year-to-date)
- ▸Partition column for reset boundary (per-account, per-year, per-month)
- ▸Source expression for what counts (raw column, CASE expression)
- Treats each variant as a new problem
- Has to remember the full syntax every time
- Slow on follow-ups, runs out of time on the third probe
- Cannot articulate the relationship between the variants
- Recognizes that all variants share the same canonical shape
- Identifies which piece needs to change and says so out loud
- Handles four follow-ups in the time most candidates handle one
- Demonstrates that the template is internalized
Reading the Running Total Row-by-Row
Discuss partition-level parallelism, pre-aggregation tradeoffs, and why running totals are expensive on streaming systems.
The scale problem
The pre-aggregation move
Pre-aggregation is the single most useful optimization for window functions on large tables. The reason: window functions cannot use indexes to skip rows the way GROUP BY can. They have to materialize the partition in memory and walk it in order. Smaller partitions mean less memory and faster walks. Saying 'I would pre-aggregate to the reporting grain before the window function' is the optimization sentence that proves you have tuned this kind of query.
The supporting index
The closing summary
> You are in a Microsoft data engineering phone screen. The interviewer asks: 'For each transaction, return the account's running balance.'
SUM(amount) OVER (PARTITION BY account_id ORDER BY txn_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), four pieces that produce one cumulative value per row.RANGE, which treats rows tied on the ORDER BY column as one peer group: three transactions on the same date read 180, 180, 180 instead of 100, 150, 180.ROWS for per-row cumulative values and RANGE only when ties genuinely should aggregate. Running totals are per-row, so the bug hides in development because test data has unique dates.6 PRECEDING gives a seven-row window, not six), the PARTITION BY for the reset boundary, and the source expression for what counts.CASE and rely on SUM ignoring NULLs, so non-qualifying rows stay in the output but contribute nothing to the cumulative value.(account_id, txn_date).The cumulative sum that trips up candidates who forget frame clauses
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: "Show Me the Running Balance", SUM OVER (ORDER BY ...) Basics, What a Window Frame Actually Is, Cumulative vs Grand Total, Reading the Running Total Row-by-Row
Lesson Sections
- "Show Me the Running Balance" (concepts: sqlAggregateOver)
The English of the question always sounds simple. 'For each transaction, show the account's running balance.' 'Show me revenue accumulated since the start of the year, broken down by day.' 'Compute the cumulative number of users who have ever logged in.' Each one is a running total. Each one wants the same SQL shape: a SUM with an OVER clause that tells the engine which rows to add up. What 'running' means, and how to spot it Why companies care Running totals power every financial and product-an
- SUM OVER (ORDER BY ...) Basics (concepts: sqlWindowFrame)
The canonical query has the same shape every time. SUM of the column to total, OVER with three parts: PARTITION BY for the group, ORDER BY for the time column, and a frame clause that says which rows to include. Memorize this shape. Type it without thinking. The query you should be able to write from memory Read the query out loud Each clause does a specific job. SUM(amount) is the value you are accumulating. PARTITION BY account_id makes each account its own running total; the balance restarts
- What a Window Frame Actually Is (concepts: sqlWindowFrame)
This section is about the one bug that every first-time author of a running total ships at least once. It is silent, it is engine-default behavior, and it produces visibly plausible numbers that are subtly wrong. The fix is one word in the query. The reason most candidates miss it is that they have never read the docs carefully enough to know that the default is wrong. The bug, stated plainly Concrete example of the bug Account A has three transactions on January 1: $100, $50, $30. The intended
- Cumulative vs Grand Total (concepts: sqlWindowFrame)
Once the canonical running total works, the interviewer will pivot. 'Now do a 7-day rolling sum.' 'Now reset every month.' 'Now only count positive transactions.' These follow-ups test whether you understand the template or memorized the canonical query. Each variant changes exactly one piece of the OVER clause; the rest stays the same. 7-day rolling sum Change the frame clause. UNBOUNDED PRECEDING becomes 6 PRECEDING. The window now contains seven rows: the current row plus the six rows immedia
- Reading the Running Total Row-by-Row (concepts: sqlAggregateOver)
After the variants, the interviewer will ask about scale. 'How does this perform on a billion-row table?' The answer at the entry level does not need to be a full architecture discussion. It needs to cover one idea: pre-aggregate. The running total runs on the daily total, not on every individual transaction. That single move is what separates the candidate who knows window functions from the candidate who knows when not to use them on raw data. The scale problem Running totals run cheaply when