Founding rate: RM5 off Basic, forever — applied automatically. See pricing

Menu

4 min read

Running totals in SQL: SUM() OVER, and the frame clause that trips people up

A cumulative sum looks like a one-liner until the default window frame gives a wrong answer for a tied row. Here is the correct pattern and the frame clause that actually controls it.

A running total — cumulative sales, a balance that grows day over day, a leaderboard score over time — is one of the first window-function questions most interview loops ask, because it's short to state and has a well-known trap in it.

The pattern

SELECT sale_date, amount,
       SUM(amount) OVER (ORDER BY sale_date) AS running_total
FROM daily_sales
ORDER BY sale_date;

No GROUP BY, no self-join. SUM(...) OVER (ORDER BY sale_date) computes, for every row, the sum of amount over every row from the start of the ordering up to and including the current one — that's the definition of a running total. The outer ORDER BY is there for display; the ORDER BY inside OVER is what actually drives the accumulation and would still be required even if you didn't care about output order.

Why this differs from a plain aggregate

GROUP BY collapses rows into one row per group. A window function does the opposite: every input row survives in the output, each carrying a value computed over a window of related rows. That's what makes it the right tool here — you want one row per day, each showing the sum-so-far, not one row per whole table.

The trap: the default frame

Here's the part that catches people who've memorized the syntax without understanding it. SUM(amount) OVER (ORDER BY sale_date) has an implicit frame clause you didn't write:

SUM(amount) OVER (
  ORDER BY sale_date
  RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

The default frame is RANGE, not ROWS. RANGE groups by the value being ordered on, not the row's position — so if two rows share the same sale_date, RANGE treats them as the same point and gives both of them the combined running total as of that date, not two separate partial sums. For a DATE column with one row per day this never bites, because dates in the demo data don't repeat. It bites the moment the same query is applied to a table with duplicate order-by values — two orders placed the same second, two transactions on the same day — and the running total silently double-counts or under-counts depending on which side of the tie a row is read on.

The fix, when ties are possible, is to be explicit about wanting row-by-row accumulation:

SUM(amount) OVER (
  ORDER BY sale_date
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

ROWS counts physical rows, not tied values, so each row gets its own distinct partial sum regardless of ties on the ordering column. As a rule: if the column you're ordering by can repeat, write ROWS explicitly rather than relying on the default.

Same shape, different aggregate

Once the pattern clicks, it's not just SUM. A running count is COUNT(*) OVER (ORDER BY ...), a running max is MAX(...) OVER (ORDER BY ...), and partitioning it per group (a running total per customer, say) is one more clause: SUM(amount) OVER (PARTITION BY customer_id ORDER BY sale_date).

More in Window functions.

Try it yourself

Browse the full SQL interview question bank →