What a running total is (and isn't)
A running total answers "what's the cumulative sum up to this point?" — useful for balance-over-time charts, cumulative revenue, or "how many units have shipped so far this month." It's easy to confuse with a regular aggregate, so it's worth being precise about the difference:
SUM(amount) GROUP BY date | SUM(amount) OVER (ORDER BY date) | |
|---|---|---|
| Rows in vs. rows out | Many rows collapse into one per group | Every row is kept, unchanged |
| What each row shows | The total for that group only | The total from the start through that row |
| Typical use | "Total sales per day" | "Cumulative sales as of each day" |
The core pattern: SUM() OVER (ORDER BY ...)
Adding OVER (...) after an aggregate function turns it from a row-collapsing GROUP BY aggregate into a window function that runs once per row while still seeing every other row in its window. The ORDER BY inside OVER() is what makes it cumulative instead of a flat total:
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders;
No GROUP BY anywhere in this query — that's the tell that it's a window function, not a regular aggregate. The result has exactly as many rows as the orders table it read from.
Worked example: daily sales running total
| order_date | amount | running_total |
|---|---|---|
| 2026-06-01 | 120 | 120 |
| 2026-06-02 | 85 | 205 |
| 2026-06-03 | 200 | 405 |
| 2026-06-04 | 60 | 465 |
| 2026-06-05 | 150 | 615 |
Each row's running_total is the sum of every amount up to and including that row's own date — 405 on June 3rd is 120 + 85 + 200, not just that day's 200.
Running total per group with PARTITION BY
To get a separate running total per category — resetting to zero at the start of each group instead of accumulating across all of them — add PARTITION BY:
SELECT
region,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY region
ORDER BY order_date
) AS running_total_by_region
FROM orders;
| region | order_date | amount | running_total_by_region |
|---|---|---|---|
| East | 2026-06-01 | 120 | 120 |
| East | 2026-06-02 | 85 | 205 |
| West | 2026-06-01 | 200 | 200 |
| West | 2026-06-02 | 60 | 260 |
West's total starts fresh at 200, completely independent of East's numbers, even though both share the same order_date values. PARTITION BY is the same mechanism used throughout window functions — see PARTITION BY Explained in SQL for the general pattern.
The frame clause, and the tie-breaking bug
Every window function has an implicit frame — the exact set of rows it looks at, relative to the current one. Left unstated, SUM() OVER (ORDER BY order_date) defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE (not ROWS) is where the bug lives: it groups every row with a tied ORDER BY value into one "peer group" and gives all of them the same, larger total.
-- Two orders placed on the SAME date
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total -- default RANGE frame
FROM orders;
| order_date | amount | running_total (RANGE, default) | running_total (ROWS, explicit) |
|---|---|---|---|
| 2026-06-01 | 100 | 100 | 100 |
| 2026-06-02 | 50 | 200 | 150 |
| 2026-06-02 | 50 | 200 | 200 |
Both June 2nd rows show 200 under the default RANGE frame, because they're tied on order_date and treated as one peer group — every row in a tied group sees the peer group's full combined total, not its own individual running position. Fix it by naming the frame explicitly:
SELECT order_date, amount,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly. It costs nothing when there are no ties and silently fixes the bug when there are.The old way: self-joins and correlated subqueries
Before window functions were widely supported, running totals were built with a correlated subquery summing all prior rows for every single row:
SELECT
o1.order_date,
o1.amount,
(SELECT SUM(o2.amount)
FROM orders o2
WHERE o2.order_date <= o1.order_date) AS running_total
FROM orders o1
ORDER BY o1.order_date;
It produces the correct result, but every output row triggers its own scan of every prior row — roughly O(n²) work for n rows. A window function computes the same result in a single ordered pass. Unless you're stuck on a database with no window function support at all, there's no reason to reach for this version.
Bonus: running average and running max
The same frame pattern works with any aggregate, not just SUM:
SELECT
order_date,
amount,
AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_avg,
MAX(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_max
FROM orders;
Dialect notes
| Database | Support |
|---|---|
| PostgreSQL, SQL Server, Oracle, Snowflake, BigQuery, Databricks | Full support, syntax as shown above |
| MySQL 8.0+ | Full support; MySQL 5.7 and earlier have no window functions — use the correlated subquery pattern instead |
| SQLite 3.25+ | Full support |
Common mistakes
- Relying on the default RANGE frame with tied ORDER BY values. The most common source of "my running total has duplicate values" bug reports.
- Forgetting PARTITION BY when a per-group total is needed. Without it, the total silently accumulates across every group in the table.
- Ordering by a column that isn't unique or stable (like a timestamp truncated to the day) without a tiebreaker — makes the row order, and therefore the running total sequence, nondeterministic on ties.
- Using the correlated-subquery version on a large table when the database supports window functions — it works, but scales far worse.
Key takeaways
- A running total is
SUM(col) OVER (ORDER BY ...)— a window function, not aGROUP BYaggregate. - Add
PARTITION BYto restart the cumulative sum separately per group. - The default frame is
RANGE, which mishandles tiedORDER BYvalues — always specifyROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWexplicitly. - The old self-join / correlated-subquery method still works but is roughly O(n²); prefer window functions wherever supported.
- The same frame pattern works with
AVG,MAX,MIN, andCOUNTfor running averages, running maximums, and so on.