Intermediate: combining and changing data · Window functions · topic 34 of 62 · 40 min
Running totals, moving averages and frames
Explanation
Running totals
A running total (or cumulative sum) answers "how much have we sold so far this month?" on every day. The daily_sales table below has the revenue of two stores, Pune and Mumbai, for six days in March 2025. Add ORDER BY inside OVER, and SUM adds up all rows from the start up to the current one.
SELECT sale_date, revenue,
SUM(revenue) OVER (ORDER BY sale_date) AS running_total
FROM daily_sales
WHERE store = 'Pune'
ORDER BY sale_date;
-- 2025-03-01 | 12000 | 12000
-- 2025-03-02 | 15000 | 27000
-- 2025-03-03 | 9000 | 36000
-- 2025-03-04 | 18000 | 54000
-- 2025-03-05 | 14000 | 68000
-- 2025-03-06 | 21000 | 89000Frames: which rows are in the window
Inside a partition, the frame is the set of rows the function actually looks at for the current row. You can write it after the ORDER BY. The words are: UNBOUNDED PRECEDING (the first row of the partition), n PRECEDING (n rows before), CURRENT ROW, n FOLLOWING and UNBOUNDED FOLLOWING (the last row).
| OVER (...) | Frame used | Typical result |
|---|---|---|
| () | All rows of the partition | Grand total on every row |
| (ORDER BY d) | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (default) | Running total, tied rows together |
| (ORDER BY d ROWS UNBOUNDED PRECEDING) | From the first row to this exact row | Running total, one row at a time |
| (ORDER BY d ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) | This row and the 2 before it | 3-row moving sum or average |
| (ORDER BY d ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) | Previous, current and next row | Centred average |
The tie surprise: RANGE versus ROWS
The default frame uses RANGE, which means "all rows whose ORDER BY value is up to the current value". Rows with the same value are called peers, and RANGE always takes all peers together. ROWS counts physical rows instead. With unique dates you never notice. With ties you do.
SELECT name, salary,
SUM(salary) OVER (ORDER BY salary) AS range_total,
SUM(salary) OVER (ORDER BY salary ROWS UNBOUNDED PRECEDING) AS rows_total
FROM employees ORDER BY salary, name;
-- ...
-- Lakshmi Pillai | 80000 | 325000 | 325000
-- Anita Desai | 95000 | 515000 | 420000
-- Vikram Singh | 95000 | 515000 | 515000
-- Farhan Ali | 105000| 620000 | 620000Moving averages
A moving average smooths out daily ups and downs. A 3-day moving average is the average of today and the two days before. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW gives exactly that frame.
SELECT sale_date, revenue,
ROUND(AVG(revenue) OVER (ORDER BY sale_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS ma_3day
FROM daily_sales
WHERE store = 'Pune'
ORDER BY sale_date;
-- 2025-03-01 | 12000 | 12000
-- 2025-03-02 | 15000 | 13500
-- 2025-03-03 | 9000 | 12000
-- 2025-03-04 | 18000 | 14000
-- 2025-03-05 | 14000 | 13666.67
-- 2025-03-06 | 21000 | 17666.67ROWS counts rows, not days. If a day is missing from the table, "2 PRECEDING" reaches further back in time. PostgreSQL 11 and later can use a time range instead: RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW. In SQLite, fill missing days first, for example with a calendar table.
Running totals per group
Combine PARTITION BY with ORDER BY and each store gets its own running total that starts again from zero.
SELECT store, sale_date, revenue,
SUM(revenue) OVER (PARTITION BY store ORDER BY sale_date) AS store_running
FROM daily_sales
ORDER BY store, sale_date;
-- Mumbai | 2025-03-01 | 20000 | 20000
-- Mumbai | 2025-03-02 | 17000 | 37000
-- ...
-- Pune | 2025-03-01 | 12000 | 12000
-- Pune | 2025-03-02 | 15000 | 27000Common mistakes
- Forgetting ORDER BY inside OVER. SUM(revenue) OVER (PARTITION BY store) is the store total on every row, not a running total.
- Running totals that jump on ties because of the default RANGE frame. Write ROWS and add a tie-breaker.
- Treating the first rows of a moving average as full averages. Show them, but know they cover fewer days; filter with COUNT(*) OVER the same frame = 3 if you need full windows only.
- Assuming ROWS BETWEEN 2 PRECEDING means 2 days. It means 2 rows; missing days break it.
- Mixing up the frame order: it is BETWEEN start AND end, and the start must come first (2 PRECEDING AND CURRENT ROW, not the other way round).
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Running totals, moving averages and frames · 1/8
Now we add ORDER BY inside OVER. That turns a sum into a running total.
Prefer a video by a teacher? Find free videos on “sql running total moving average window frame rows between range” on YouTube ↗
In real life: Month-to-date sales on a store dashboard
Retail chains and food delivery companies track "month-to-date" sales against a target every morning. The dashboard query is usually a running SUM over the daily totals, partitioned by store or city, and a 7-day moving average to smooth out weekends and festival spikes, so managers see the trend instead of daily noise.
SELECT sale_date,
SUM(revenue) AS day_total,
SUM(SUM(revenue)) OVER (ORDER BY sale_date) AS month_to_date
FROM daily_sales
GROUP BY sale_date
ORDER BY sale_date;Practice (0/4 done)
daily_sales holds the revenue of two stores per day. For the Pune store only, return sale_date, revenue and the running total of revenue by date named running_total, sorted by sale_date.
For the Pune store, return sale_date, revenue and the 3-day moving average (this day and the 2 rows before it) rounded to 2 decimal places, named ma_3day, sorted by sale_date.
Give each store its own running total. Return store, sale_date, revenue and the running total within the store named store_running, sorted by store, then sale_date.
Both stores together: return one row per sale_date with the day's total revenue of all stores named day_total, and the running total of those day totals named running_total, sorted by sale_date. (Hint: a window over a GROUP BY.)
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- LAG, LEAD, FIRST_VALUE and LAST_VALUEWindow functions · same module
- Counting and totals: aggregate functionsBackend Development · Databases and SQL basics
- Writing reports with aggregationAggregation: GROUP BY and HAVING
- GROUP BY and HAVINGBackend Development · SQL in depth
- Aggregate functionsAggregation: GROUP BY and HAVING
- Sliding window: fixed sizeData Structures and Algorithms (DSA) for interviews · Hashing, two pointers and sliding window
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
What is the default frame when OVER has an ORDER BY but no frame clause?
Answer: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
That default is what makes ORDER BY produce a running total.
Two rows have the same ORDER BY value. With the default frame, what running total do they get?
Answer: The same total, including both rows
RANGE treats peers (equal values) together, so both rows include each other.
Which frame gives a 3-day moving average when there is one row per day?
Answer: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
Two rows before plus the current row is three rows.
What does SUM(revenue) OVER (PARTITION BY store) give, with no ORDER BY?
Answer: The full total of the store on every row of that store
Without ORDER BY the frame is the whole partition.
One day is missing from daily_sales. What happens to ROWS BETWEEN 2 PRECEDING AND CURRENT ROW around the gap?
Answer: It still covers 3 rows, which now span more than 3 days
ROWS counts rows, not dates. Fill the gaps or use a time RANGE where supported.
A table has 10 rows; the email column is NULL in 3 of them. What is COUNT(email)?Related
Answer: 7
COUNT(column) counts only rows where the column has a value: 10 - 3 = 7.
Why does LAST_VALUE(revenue) OVER (ORDER BY sale_date) just return each row's own revenue?Related
Answer: The default frame ends at the current row, so the current row is the last one in the frame
Widen the frame to UNBOUNDED FOLLOWING, or use FIRST_VALUE with ORDER BY ... DESC.
students has 8 rows in 5 different cities. How many rows does SELECT city, COUNT(*) FROM students GROUP BY city return?Related
What does SUM(salary) OVER () put on each row?Related
Answer: The total of all salaries in the result
Empty brackets make the window the whole result, so every row shows the same grand total.
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- 2 more topics in “Window functions” before its exam: LAG, LEAD, FIRST_VALUE and LAST_VALUE, Top-N per group and de-duplication.
- Module 7 exam: Window functions · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Changing data, transactions and ACID. 62 topics left in the course before the final exam.