Hook: Interviewers love this one because it looks simple, but it quietly checks whether you can turn raw transactions into an ordered story without making the math lie.
Question: How do you calculate a running balance in SQL?
Answer: A running balance is the total after each row, based on all earlier rows in the correct order. In SQL, the cleanest way is a window function: SUM(amount) OVER (...) . You usually partition by account, order by time plus a unique tie-breaker, and, if needed, add an opening balance once at the start.
Interview-Ready Answer: I’d use a windowed SUM to compute the balance row by row. I partition by account, order by transaction time plus a unique id so the order is deterministic, and I use a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW frame so each row includes every prior transaction. If there is an opening balance, I add it once at the end. The main detail I’d call out is that ordering only by timestamp can be wrong when two transactions happen at the same time.
Detailed Explanation: Think of a ledger like a receipt tape. Every new line changes the total, but only if you read the lines in the right order. A running balance is just the cumulative total after each row, often per account, user, or wallet.
PARTITION BY account_id when each account needs its own balance.txn_id so two rows at the same time cannot swap places.SUM(amount) OVER (...) tells the database to keep adding the current row to all earlier rows in that partition.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW means: from the first row in the partition up to this row, one row at a time.| Approach | Best for | Trade-off |
|---|---|---|
Window SUM | Readable, fast, common | Needs correct ordering |
| Self join | Older SQL systems | Harder to read, slower on big tables |
| Correlated subquery | Simple demos | Often O(n²) work |
| Recursive CTE | Step-by-step logic | More verbose than needed |
Most databases sort each partition first, then scan it once to keep the running total. That usually means the query is about O(n log n) because of sorting, then O(n) for the accumulation. On a million-row ledger, that is normal and efficient; on a 50-million-row table, the sort and memory use become noticeable. A helpful index is often something like (account_id, txn_ts, txn_id), because it matches the partition and order.
One subtle gotcha: if you write only ORDER BY txn_ts, many engines treat equal timestamps as peers. With the default frame in some systems, peer rows can share the same frame, which makes the result less intuitive. For running balances, the safest habit is to be explicit with ROWS and include a unique tie-breaker.
SUM ignores nulls, but if every value in a partition is null, the result can be null, so COALESCE is useful.Real-World Story: Imagine a wallet service inside a checkout app. Every purchase, refund, service fee, and manual adjustment must update the customer’s balance in the exact order it happened. The finance team uses the running balance to reconcile statements, and support uses it to explain why a user is short by a few cents.
What goes wrong when people misunderstand this? A team once ordered only by timestamp, and two same-second transactions flipped depending on the plan chosen by the database. In the UI, some users saw the balance jump by a few cents between refreshes. In logs, the reconciliation job reported mismatches like ‘ledger total does not match statement total’, and support tickets mentioned duplicate-looking charges even though the real bug was ordering, not billing.
The symptom is usually not a crash. It is worse: silent financial drift, failed audits, angry users, and a painful backfill job to recompute every balance with the correct order. That is why engineers care so much about deterministic ordering and explicit window frames.
-- Running balance example: PostgreSQL-compatible SQL
-- This shows: per-account running totals, opening balance, negative amounts,
-- and a NULL amount edge case. The txn_id tie-breaker makes same-timestamp
-- rows deterministic; without it, the order can be ambiguous.
WITH opening_balance(account_id, opening_balance) AS (
VALUES
(1, CAST(100.00 AS numeric(12,2))),
(2, CAST(50.00 AS numeric(12,2)))
),
transactions(account_id, txn_id, txn_ts, description, amount) AS (
VALUES
(1, 101, TIMESTAMP '2026-01-01 09:00:00', 'Deposit', CAST(25.00 AS numeric(12,2))),
(1, 102, TIMESTAMP '2026-01-01 09:00:00', 'Coffee', CAST(-5.50 AS numeric(12,2))),
(1, 103, TIMESTAMP '2026-01-01 12:15:00', 'Lunch', CAST(-18.25 AS numeric(12,2))),
(1, 104, TIMESTAMP '2026-01-02 08:30:00', 'Refund', CAST(10.00 AS numeric(12,2))),
(2, 201, TIMESTAMP '2026-01-01 10:00:00', 'Top up', CAST(40.00 AS numeric(12,2))),
(2, 202, TIMESTAMP '2026-01-01 10:05:00', 'Fee', CAST(-2.00 AS numeric(12,2))),
(2, 203, TIMESTAMP '2026-01-01 10:05:00', 'Adjustment', CAST(NULL AS numeric(12,2)))
),
base AS (
SELECT
t.account_id,
t.txn_id,
t.txn_ts,
t.description,
t.amount,
COALESCE(o.opening_balance, CAST(0 AS numeric(12,2))) AS opening_balance
FROM transactions t
LEFT JOIN opening_balance o
ON o.account_id = t.account_id
)
SELECT
account_id,
txn_id,
txn_ts,
description,
amount,
opening_balance,
opening_balance
+ COALESCE(
SUM(amount) OVER (
PARTITION BY account_id
ORDER BY txn_ts, txn_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
),
CAST(0 AS numeric(12,2))
) AS running_balance
FROM base
ORDER BY account_id, txn_ts, txn_id;Follow-up & Tricky Questions:
ORDER BY in the outer query enough? No. The ordering inside OVER (...) defines the math; outer ORDER BY only changes display order.SUM(amount) OVER (ORDER BY txn_ts) always behave like a row-by-row running total? Not necessarily. Duplicate timestamps can form peers, so use ROWS and a tie-breaker to make the result deterministic.NULL amounts count as zero? SUM ignores null inputs, but if all rows in a frame are null the result can still be null. Use COALESCE if you need a guaranteed numeric answer.Common Mistakes:
PARTITION BY account_id unless one global balance is truly intended.txn_id so same-time rows never swap.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly.Memory Hook: Think of a running balance as a receipt tape: every line is added in order, and if two lines share the same time, you still need a unique line number to keep the tape honest.
Cheat Sheet:
SUM(amount) OVER (...) for cumulative totals.ROWS for true row-by-row accumulation.Practice Tasks:
account_id.