Skip to content

Intermediate: combining and changing data · Window functions · topic 34 of 62 · 40 min

Running totals, moving averages and frames

Explanation

When you put ORDER BY inside OVER, an aggregate becomes cumulative: SUM(revenue) OVER (ORDER BY sale_date) is a running total. Which rows are added up is set by the frame. The default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which treats tied rows together; ROWS BETWEEN 2 PRECEDING AND CURRENT ROW gives a 3-day moving average.

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.

sql
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 | 89000
Each row adds its own revenue to the total so far.

Frames: 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 usedTypical result
()All rows of the partitionGrand 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 rowRunning total, one row at a time
(ORDER BY d ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)This row and the 2 before it3-row moving sum or average
(ORDER BY d ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)Previous, current and next rowCentred 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.

sql
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 | 620000
Anita and Vikram are peers (both 95000), so the default RANGE frame gives both of them 515000.
Tip: If you want a running total that grows on every row, write ROWS explicitly and add a tie-breaker: OVER (ORDER BY salary, id ROWS UNBOUNDED PRECEDING).

Moving 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.

sql
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.67
The first two rows average only 1 and 2 days, because there are no earlier rows.

ROWS 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.

sql
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 | 27000

Common 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

Running totals and frames

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.

sql
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;
Both stores together: 32000, 64000, 95000, 132000, 171000, 215000 by 6 March.

Practice (0/4 done)

Task 1 · Code · SQL

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.

Task 2 · Code · SQL

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.

Task 3 · Code · SQL

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.

Task 4 · Code · SQL

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

Question 1

1.What is the default frame when OVER has an ORDER BY but no frame clause?

Question 2

2.Two rows have the same ORDER BY value. With the default frame, what running total do they get?

Question 3

3.Which frame gives a 3-day moving average when there is one row per day?

Question 4

4.What does SUM(revenue) OVER (PARTITION BY store) give, with no ORDER BY?

Question 5

5.One day is missing from daily_sales. What happens to ROWS BETWEEN 2 PRECEDING AND CURRENT ROW around the gap?

Read more (free)

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.

From Counting and totals: aggregate functions

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.

From LAG, LEAD, FIRST_VALUE and LAST_VALUE

students has 8 rows in 5 different cities. How many rows does SELECT city, COUNT(*) FROM students GROUP BY city return?Related

Answer: 5

GROUP BY gives one row per group, and there are 5 cities.

From GROUP BY

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.

From OVER and PARTITION BY

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

  1. 2 more topics in “Window functions” before its exam: LAG, LEAD, FIRST_VALUE and LAST_VALUE, Top-N per group and de-duplication.
  2. Module 7 exam: Window functions · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Changing data, transactions and ACID. 62 topics left in the course before the final exam.

← Previous: Ranking: ROW_NUMBER, RANK, DENSE_RANK and NTILE