Hook: Interviewers love this question because it looks simple, but it quietly tests whether you understand ordered data, window functions, and one very common trap: duplicate sort keys.
Question: How do I calculate a running total in SQL?
Answer: A running total is a cumulative sum: each row shows the total of all rows before it plus the current row. In SQL, the cleanest way is a window function with SUM(...) OVER (...)`, usually with ORDER BY and often PARTITION BY if you want the total to restart for each group. The important detail is to use the right window frame so rows are accumulated one by one, not lumped together unexpectedly.
Interview-Ready Answer: I would use a window function, usually SUM(amount) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). That gives me a cumulative sum without collapsing the rows the way GROUP BY would. I also like to add a unique tie-breaker in the ORDER BY so the result is deterministic when multiple rows share the same date or timestamp.
A running total is just a cumulative sum. If the amounts are 10, 7, and 3, the running total is 10, 17, and 20. In SQL, the key idea is that we want one output row per input row, but each row should be allowed to look backward at earlier rows in a chosen order. That is exactly what a window function does: it computes across a related set of rows while keeping each row visible.
ORDER BY clause inside the window, because a running total only makes sense in a defined sequence.PARTITION BY, the engine splits the data into separate buckets first, such as one bucket per customer or per store.SUM(amount) over that frame and emits the result next to the original row.ROWS usually mattersThe subtle part is the frame type. Many candidates write only ORDER BY and assume the database will behave like ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. In many engines, the default frame with ORDER BY behaves more like RANGE, which groups rows that tie on the ordering value. That can make two rows with the same date share the same running total, which is often not what you want.
| Frame | What it means | When it is safest |
|---|---|---|
ROWS | Counts physical rows one by one | Use for true row-by-row running totals |
RANGE | Groups peers with the same sort key | Use when equal values should move together |
Memory aid: think of ROWS as a staircase and RANGE as a landing. A staircase moves one step at a time; a landing jumps you across all equal-value peers at once.
Use PARTITION BY when the total must reset. For example, if you are tracking sales per store, each store should get its own running sum. Without partitioning, all stores get mixed into one long total.
A good mental model is: sort first, then scan once. The sort is usually the expensive part, so the total cost is often dominated by sorting, roughly O(n log n), followed by an O(n) pass to accumulate values. If the rows are already read in the needed order, or an index matches PARTITION BY plus ORDER BY, the engine may do less work. On large tables, the sort can spill to disk if memory is too small; that is when a query that looks harmless suddenly gets slow.
| Approach | How it works | Main trade-off |
|---|---|---|
| Window function | One ordered pass | Clean and fast on modern DBs |
| Correlated subquery | Re-sums prior rows each time | Simple to read, often slow |
| Self join | Joins each row to earlier rows | Works on old systems, but verbose |
Window functions are supported in modern versions of PostgreSQL, SQL Server, MySQL 8+, Oracle, and SQLite 3.25+. If you are on an older database, you may need a self-join or correlated subquery, but the window version is usually the right answer in interviews because it is clearer and more efficient.
SUM ignores them, but COALESCE(amount, 0) makes the intent obvious.ORDER BY: a running total needs order; without it, the concept breaks.Real-World Story: Imagine a checkout service for an online store that shows live revenue per store on a manager dashboard. Each payment event arrives with a store ID, timestamp, and amount, and the dashboard needs a growing sales total every minute. One team wrote a query with only ORDER BY event_time, and when two payments landed in the same second, the totals appeared to jump in awkward chunks instead of increasing row by row.
What went wrong showed up fast: finance noticed mismatched totals between the dashboard and the ledger, support saw screenshots where two same-second transactions displayed identical cumulative values, and logs showed duplicate timestamps in bursts during peak traffic. The fix was to use a deterministic order such as ORDER BY event_time, event_id and an explicit row frame so the running total advanced one payment at a time. That kind of bug is especially nasty because the data is not missing - it is just being summarized with the wrong mental model.
-- Running total example with a real edge case: duplicate ordering values.
-- The data has two sales on the same day for each store, plus a refund and a NULL amount.
-- `ROWS` gives a true row-by-row cumulative total.
-- `RANGE` groups equal ORDER BY values together, which can surprise you when dates repeat.
WITH sales(txn_id, store_id, txn_day, amount) AS (
VALUES
(1, 'A', 1, 100),
(2, 'A', 1, 25),
(3, 'A', 2, -10),
(4, 'A', 3, 40),
(5, 'B', 1, 60),
(6, 'B', 2, 15),
(7, 'B', 2, 5),
(8, 'B', 3, NULL)
)
SELECT
store_id,
txn_day,
txn_id,
amount,
-- This is the safest pattern for a running total: explicit frame, deterministic order.
SUM(COALESCE(amount, 0)) OVER (
PARTITION BY store_id
ORDER BY txn_day, txn_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total_rows,
-- Same data, but peer-group behavior can make same-day rows share the same total.
SUM(COALESCE(amount, 0)) OVER (
PARTITION BY store_id
ORDER BY txn_day
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total_range_peers
FROM sales
ORDER BY store_id, txn_day, txn_id;Follow-up & Tricky Questions:
PARTITION BY customer_id. That creates an independent cumulative sum inside each customer group.ROWS and RANGE? ROWS counts physical rows one by one, while RANGE includes all peers with the same sort value in the current frame.txn_id or created_at, id. Otherwise the database may not guarantee the order among tied rows.ROWS BETWEEN 6 PRECEDING AND CURRENT ROW for rows, or a time-based frame where your database supports it.SUM(amount) OVER (ORDER BY day), is that always row-by-row? No. Many databases use a peer-aware default frame, so duplicates can share the same cumulative value. Be explicit with ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.GROUP BY solve this? No. GROUP BY collapses rows into one result per group, while a running total must preserve every row.GROUP BY instead of a window function - This collapses rows. Fix: use SUM(...) OVER (...) so each original row stays visible.ORDER BY - A running total has no meaning without sequence. Fix: order by a column that reflects time or business order.ROWS - Duplicate sort values can get grouped together. Fix: spell out ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.Think of a running total like a shopping cart belt: every new item stays on the belt, and the number shown at each point is the total of everything already passed. ROWS moves item by item; RANGE can sweep all identical items onto the same spot.
SUM(amount) OVER (...) for cumulative totals.PARTITION BY to restart totals per group.ORDER BY to define the sequence.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW for row-by-row behavior.PARTITION BY.ROWS and RANGE differ.