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 dateSUM(amount) OVER (ORDER BY date)
Rows in vs. rows outMany rows collapse into one per groupEvery row is kept, unchanged
What each row showsThe total for that group onlyThe 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:

core-pattern.sql
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_dateamountrunning_total
2026-06-01120120
2026-06-0285205
2026-06-03200405
2026-06-0460465
2026-06-05150615

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:

partition-running-total.sql
SELECT
  region,
  order_date,
  amount,
  SUM(amount) OVER (
    PARTITION BY region
    ORDER BY order_date
  ) AS running_total_by_region
FROM orders;
regionorder_dateamountrunning_total_by_region
East2026-06-01120120
East2026-06-0285205
West2026-06-01200200
West2026-06-0260260

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.

tie-bug.sql
-- 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_dateamountrunning_total (RANGE, default)running_total (ROWS, explicit)
2026-06-01100100100
2026-06-0250200150
2026-06-0250200200

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:

correct-frame.sql
SELECT order_date, amount,
  SUM(amount) OVER (
    ORDER BY order_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_total
FROM orders;
Rule of thumb: whenever you write a cumulative window function, add 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:

old-correlated-subquery.sql
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:

other-aggregates.sql
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

DatabaseSupport
PostgreSQL, SQL Server, Oracle, Snowflake, BigQuery, DatabricksFull 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 a GROUP BY aggregate.
  • Add PARTITION BY to restart the cumulative sum separately per group.
  • The default frame is RANGE, which mishandles tied ORDER BY values — always specify ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly.
  • 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, and COUNT for running averages, running maximums, and so on.