SQL · Guides · October 29, 2026 · 13 min read

Running total in SQL: cumulative sum, by group, resets and frames

A row of dark bars growing taller from left to right with a green line climbing along their tops and a figure walking up steps

A running total (also called a cumulative sum) adds each row’s value to everything before it: day 1, then day 1 + day 2, then day 1 + day 2 + day 3, and so on. In modern SQL you write it with a window function: SUM(value) OVER (ORDER BY day). Add PARTITION BY and the total starts again for every group; add a frame clause such as ROWS BETWEEN 6 PRECEDING AND CURRENT ROW and it becomes a moving sum instead.

That one line hides several decisions: what happens with ties in the sort column, whether a filter runs before or after the window, and how a frame behaves when some days are missing. This guide walks through each of them on a real dataset: a training airline database with bookings, tickets and flights from summer 2026 (money in US dollars). Every query below was run on it, and every table shows the actual output, so you can paste any example into the Nomto SQL room and get the same numbers.

The basic running total with SUM() OVER

Start with daily revenue from the bookings table, then add a cumulative column:

SELECT
  CAST(book_date AS DATE) AS day,
  COUNT(*) AS bookings,
  SUM(total_amount) AS revenue,
  SUM(SUM(total_amount)) OVER (ORDER BY CAST(book_date AS DATE)) AS running_revenue
FROM bookings
WHERE book_date >= '2026-08-01'
GROUP BY CAST(book_date AS DATE)
ORDER BY day
LIMIT 7;
day bookings revenue running_revenue
2026-08-01 5746 4504496.00 4504496.00
2026-08-02 5761 4563873.00 9068369.00
2026-08-03 5665 4521583.00 13589952.00
2026-08-04 5813 4676666.00 18266618.00
2026-08-05 5937 4793778.00 23060396.00
2026-08-06 6081 4856044.00 27916440.00
2026-08-07 6184 4907956.00 32824396.00

Two things are worth noticing.

SUM(SUM(...)) OVER is not a typo. The inner SUM is the ordinary aggregate that GROUP BY computes for each day. The outer SUM ... OVER is a window function that runs after grouping and adds up those daily values. Window functions are evaluated after WHERE, GROUP BY and HAVING, so they can see aggregated results.

The running total starts at 1 August, not at the first booking. The WHERE clause removed everything earlier before the window was computed. Sometimes that is what you want (“revenue since the start of the month”); often it is not. The next section shows the difference.

Most people find the version with a CTE easier to read: first build the daily table, then add the window on top.

WITH daily AS (
  SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
  FROM bookings
  GROUP BY CAST(book_date AS DATE)
)
SELECT day, revenue,
  SUM(revenue) OVER (ORDER BY day) AS running_revenue
FROM daily
ORDER BY day;

Filter after the window, not before

Suppose you want month-to-date revenue for the last days of July and the first days of August. A tempting query puts the date filter in the same SELECT as the window. The WHERE runs first, so July’s running total would start at 29 July and lose everything from 1 to 28 July.

The fix is to compute the window in one step and filter in the next:

WITH daily AS (
  SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
  FROM bookings
  GROUP BY CAST(book_date AS DATE)
),
mtd AS (
  SELECT day, revenue,
    SUM(revenue) OVER (PARTITION BY date_trunc('month', day) ORDER BY day) AS mtd_revenue
  FROM daily
)
SELECT *
FROM mtd
WHERE day BETWEEN '2026-07-29' AND '2026-08-03'
ORDER BY day;
day revenue mtd_revenue
2026-07-29 4423319.00 127170650.00
2026-07-30 4284520.00 131455170.00
2026-07-31 4517665.00 135972835.00
2026-08-01 4504496.00 4504496.00
2026-08-02 4563873.00 9068369.00
2026-08-03 4521583.00 13589952.00

Now 29 July carries the whole July total up to that day, $127.2 million, and the counter drops back to one day’s revenue on 1 August. If the filter had been in the same query as the window, the 29 July row would have shown only 4,423,319.

Rule of thumb: conditions that define the population go in WHERE before the window; conditions that only choose which rows to display go in an outer query after it.

Running total that resets: PARTITION BY

The query above already used the reset mechanism. PARTITION BY splits the rows into independent groups, and the running total starts from zero in each one. Partition by a calendar period and you get month-to-date or year-to-date figures; partition by a category and you get a separate cumulative line per category.

Here is revenue from flight segments by departure day, with a separate running total for each cabin class:

WITH daily AS (
  SELECT CAST(f.scheduled_departure AS DATE) AS day,
    tf.fare_conditions,
    SUM(tf.amount) AS revenue
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  GROUP BY CAST(f.scheduled_departure AS DATE), tf.fare_conditions
)
SELECT day, fare_conditions, revenue,
  SUM(revenue) OVER (PARTITION BY fare_conditions ORDER BY day) AS running_revenue
FROM daily
WHERE day <= '2026-07-14'
ORDER BY fare_conditions, day;
day fare_conditions revenue running_revenue
2026-07-12 Business 446047.00 446047.00
2026-07-13 Business 585901.00 1031948.00
2026-07-14 Business 571886.00 1603834.00
2026-07-12 Comfort 60600.00 60600.00
2026-07-13 Comfort 60147.00 120747.00
2026-07-14 Comfort 66747.00 187494.00
2026-07-12 Economy 1159688.00 1159688.00
2026-07-13 Economy 1507831.00 2667519.00

(Eight of nine rows shown; Economy on 14 July reaches 4267240.00.)

The WHERE day <= '2026-07-14' here is safe: flights start on 12 July, so cutting off the end of the period does not change the totals of the rows that remain. A filter on the end of a range never affects a running total that looks backwards; a filter on the start does.

You can partition by several columns at once, for example PARTITION BY fare_conditions, date_trunc('month', day) for a month-to-date figure per class.

Ties: why ROWS and RANGE give different answers

When you write ORDER BY inside OVER and no frame, SQL uses the default frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. “RANGE” means “every row with a sort value up to and including the current one”, and that includes all rows that tie with the current row. With unique sort values you never notice. With duplicates, tied rows get the same running total.

Bookings are stored to the minute, and sometimes two land on the same minute:

WITH t AS (
  SELECT book_ref, book_date, total_amount,
    SUM(total_amount) OVER (ORDER BY book_date) AS range_total,
    SUM(total_amount) OVER (ORDER BY book_date
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_total,
    SUM(total_amount) OVER (ORDER BY book_date, book_ref) AS tiebroken
  FROM bookings
)
SELECT *
FROM t
WHERE book_date BETWEEN '2026-06-20 01:58' AND '2026-06-20 02:21'
ORDER BY book_date, book_ref;
book_ref book_date total_amount range_total rows_total tiebroken
FBD168 2026-06-20 01:58:00 1304.00 34666.00 34666.00 34666.00
6E9274 2026-06-20 02:00:00 506.00 35172.00 35172.00 35172.00
0477BC 2026-06-20 02:01:00 258.00 35690.00 35430.00 35430.00
A06421 2026-06-20 02:01:00 260.00 35690.00 35690.00 35690.00
D18DDD 2026-06-20 02:21:00 751.00 36441.00 36441.00 36441.00

The two bookings at 02:01 both show 35690.00 under the default RANGE frame: each one “sees” the other. With ROWS, the first of the pair shows 35430.00 and the second 35690.00, a proper step-by-step total.

But ROWS alone has its own problem: the order of tied rows is not defined, so which booking comes first can change between runs or databases. The robust fix is the last column: add a unique tie-breaker to the ORDER BY (book_date, book_ref). Then every row has its own position, RANGE and ROWS agree, and the result is repeatable.

Which is correct depends on the question:

Moving sums: ROWS BETWEEN n PRECEDING

Change the start of the frame and the running total becomes a moving (rolling) total. A 7-day rolling revenue:

WITH daily AS (
  SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
  FROM bookings
  GROUP BY CAST(book_date AS DATE)
)
SELECT day, revenue,
  SUM(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7d,
  COUNT(*) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS days_in_window
FROM daily
ORDER BY day
LIMIT 8;
day revenue rolling_7d days_in_window
2026-06-17 4419.00 4419.00 1
2026-06-18 7753.00 12172.00 2
2026-06-19 18220.00 30392.00 3
2026-06-20 59770.00 90162.00 4
2026-06-21 153054.00 243216.00 5
2026-06-22 290491.00 533707.00 6
2026-06-23 543399.00 1077106.00 7
2026-06-24 912564.00 1985251.00 7

The days_in_window column shows a trap at the start of a series: the first six rows have fewer than seven days in their window, so their “7-day” sums are not comparable with later ones. Either hide them (WHERE days_in_window = 7 in an outer query) or label them clearly. For a rolling average, AVG over the same frame divides by the number of rows actually in the window, which hides the problem even better, so always check the count.

When days are missing: RANGE with an interval

ROWS BETWEEN 6 PRECEDING means “the previous six rows”, not “the previous six days”. If the series has gaps, six rows can cover two weeks. Flights from Moscow Domodedovo (DME) to Petropavlovsk-Kamchatsky (PKC) do not run every day, which makes the difference obvious:

WITH daily AS (
  SELECT CAST(f.scheduled_departure AS DATE) AS day, COUNT(*) AS passengers
  FROM flights f
  JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  WHERE f.departure_airport = 'DME' AND f.arrival_airport = 'PKC'
  GROUP BY CAST(f.scheduled_departure AS DATE)
)
SELECT day, passengers,
  SUM(passengers) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rows_7,
  SUM(passengers) OVER (ORDER BY day
    RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) AS range_7d
FROM daily
ORDER BY day
LIMIT 8;
day passengers rows_7 range_7d
2026-07-13 43 43 43
2026-07-16 66 109 109
2026-07-18 44 153 153
2026-07-20 52 205 162
2026-07-23 95 300 191
2026-07-25 58 358 205
2026-07-27 63 421 216
2026-07-30 92 470 213

By 30 July the ROWS version adds up seven departures spread over 15 days, 16 to 30 July (470 passengers), while the RANGE version correctly covers 24–30 July only (213). RANGE with an interval offset works in PostgreSQL 11 and later when the sort column is a date or timestamp.

The other cure is to fill the missing days with zeros before applying the window, using a calendar built with generate_series. That approach is covered step by step in our guide on filling missing dates with a date spine.

A quick summary of frames

Frame What it adds up Typical use
no frame (default RANGE ... CURRENT ROW) all earlier rows plus ties cumulative total by date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW all earlier rows, one step per row running balance per transaction
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW this row and the six before it rolling sum on a gap-free daily series
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW rows within the last 7 calendar days rolling sum when days can be missing
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING the whole partition total for the share calculation

Practical patterns built on a running total

When did the total cross a threshold?

Compute the running total, then take the first row where it reaches the target. Here: the day cumulative booking revenue first passed $100 million.

WITH daily AS (
  SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
  FROM bookings
  GROUP BY CAST(book_date AS DATE)
),
cum AS (
  SELECT day, SUM(revenue) OVER (ORDER BY day) AS running_revenue
  FROM daily
)
SELECT day, running_revenue
FROM cum
WHERE running_revenue >= 100000000
ORDER BY day
LIMIT 1;
day running_revenue
2026-07-19 102940157.00

You cannot put the window function itself in WHERE; that is why the running total lives in a CTE first.

Cumulative share (Pareto analysis)

Divide a running total by the grand total and you get a cumulative share. Sort routes by revenue, and you can see how concentrated the business is:

WITH route AS (
  SELECT f.departure_airport || '-' || f.arrival_airport AS route,
    SUM(tf.amount) AS revenue
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  GROUP BY f.departure_airport || '-' || f.arrival_airport
),
ranked AS (
  SELECT route, revenue,
    SUM(revenue) OVER (ORDER BY revenue DESC, route
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_revenue,
    SUM(revenue) OVER () AS total,
    ROW_NUMBER() OVER (ORDER BY revenue DESC, route) AS rn,
    COUNT(*) OVER () AS routes
  FROM route
)
SELECT rn, route, revenue,
  ROUND(100.0 * cum_revenue / total, 1) AS cum_share_pct,
  routes
FROM ranked
WHERE rn <= 5
ORDER BY rn;
rn route revenue cum_share_pct routes
1 DME-KHV 7534783.00 3.6 457
2 KHV-DME 7337978.00 7.2 457
3 DME-OVB 5482189.00 9.8 457
4 OVB-DME 5315037.00 12.4 457
5 KHV-LED 5076724.00 14.8 457

Two windows sit side by side: SUM(revenue) OVER () with an empty OVER is the grand total on every row, and the ordered one is the running total. Counting how many rows have cum_share up to 0.5 shows that 39 of the 457 routes bring in half of the segment revenue. Note 100.0 rather than 100: in PostgreSQL, integer divided by integer drops the fraction.

Older alternatives and performance

Before window functions, running totals were written with a correlated subquery or a self-join: for each row, sum all rows with an earlier date. It gives the same result but reads the table once per row, so it slows down quadratically as the table grows. A window function sorts once and walks the rows in a single pass. In PostgreSQL an index on the PARTITION BY and ORDER BY columns can save the sort; on aggregated daily tables the cost is negligible either way.

If you need the running total in several places, compute it once in a CTE or a view instead of repeating the window.

Common mistakes checklist

Every query in this guide uses tables from the Nomto SQL course, so you can run each one in the SQL room, change the partition or the frame, and watch how the numbers move.

FAQ

How do I calculate a running total in SQL?

Use a window function: SUM(amount) OVER (ORDER BY date_column). If the data has several rows per date, aggregate first (with GROUP BY or in a CTE) and apply the window to the daily totals.

How do I make a running total reset for each group or month?

Add PARTITION BY inside OVER: SUM(revenue) OVER (PARTITION BY customer_id ORDER BY day) for a per-customer total, or PARTITION BY date_trunc('month', day) for month-to-date.

What is the difference between ROWS and RANGE in a running total?

ROWS counts physical rows; RANGE works with values of the sort column, so rows with equal values (ties) are treated as one step and get the same total. With a unique sort key both give the same result. RANGE also accepts interval offsets like INTERVAL '6 days' PRECEDING.

Can I use a running total in a WHERE clause?

Not directly, because window functions are computed after WHERE. Put the running total in a CTE or subquery and filter on it in the outer query.

Does this syntax work in MySQL and SQL Server?

SUM() OVER (PARTITION BY ... ORDER BY ...) works in PostgreSQL, MySQL 8+, SQL Server, Oracle, SQLite 3.25+ and most analytical databases. Support for RANGE with interval offsets varies; PostgreSQL has it from version 11.

Also in 简体中文