SQL Running Totals and Moving Averages
A running total adds each row to everything before it: revenue to date, stock level after each movement, headcount over time. A moving average smooths a series over the last few rows. Both are one window function each, once you understand the frame: which rows around the current one are included.
- Build running totals with
SUM() OVER (ORDER BY ...) - Restart totals per group with
PARTITION BY - Control the frame with
ROWS BETWEEN - Calculate moving averages and percent of total
Daily revenue
All examples use the revenue per order date, built in a CTE:
WITH daily AS ( SELECT o.order_date, SUM(oi.quantity * p.price) AS revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.status <> 'Cancelled' GROUP BY o.order_date)SELECT * FROM daily ORDER BY order_date;| order_date | revenue |
|---|---|
| 2025-01-05 | 4744 |
| 2025-01-17 | 8999 |
| 2025-02-02 | 4298 |
| 2025-03-08 | 3286 |
| 2025-03-22 | 5497 |
| 2025-04-10 | 799 |
| 2025-04-28 | 20996 |
| 2025-05-15 | 3499 |
| 2025-06-01 | 1145 |
Running total
WITH daily AS ( SELECT o.order_date, SUM(oi.quantity * p.price) AS revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.status <> 'Cancelled' GROUP BY o.order_date)SELECT order_date, revenue, SUM(revenue) OVER (ORDER BY order_date) AS revenue_to_dateFROM dailyORDER BY order_date;| order_date | revenue | revenue_to_date |
|---|---|---|
| 2025-01-05 | 4744 | 4744 |
| 2025-01-17 | 8999 | 13743 |
| 2025-02-02 | 4298 | 18041 |
| 2025-03-08 | 3286 | 21327 |
| 2025-03-22 | 5497 | 26824 |
| 2025-04-10 | 799 | 27623 |
| 2025-04-28 | 20996 | 48619 |
| 2025-05-15 | 3499 | 52118 |
| 2025-06-01 | 1145 | 53263 |
- The CTE gives one row per day with that day’s revenue.
SUM(revenue) OVER (ORDER BY order_date)adds the revenue of the current day and every earlier day.- The last row’s running total is the grand total of all non-cancelled orders.
Running total per group
Each customer’s cumulative spend, order by order:
WITH order_totals AS ( SELECT o.customer_id, o.order_id, o.order_date, SUM(oi.quantity * p.price) AS total FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.status <> 'Cancelled' GROUP BY o.customer_id, o.order_id, o.order_date)SELECT customer_id, order_id, order_date, total, SUM(total) OVER (PARTITION BY customer_id ORDER BY order_date) AS customer_to_dateFROM order_totalsORDER BY customer_id, order_date;| customer_id | order_id | order_date | total | customer_to_date |
|---|---|---|---|---|
| 1 | 1001 | 2025-01-05 | 4744 | 4744 |
| 1 | 1003 | 2025-02-02 | 4298 | 9042 |
| 1 | 1008 | 2025-04-28 | 20996 | 30038 |
| 2 | 1002 | 2025-01-17 | 8999 | 8999 |
| 2 | 1007 | 2025-04-10 | 799 | 9798 |
| 3 | 1009 | 2025-05-15 | 3499 | 3499 |
| 4 | 1005 | 2025-03-08 | 3286 | 3286 |
| 5 | 1006 | 2025-03-22 | 5497 | 5497 |
| 5 | 1010 | 2025-06-01 | 1145 | 6642 |
PARTITION BY customer_idrestarts the running total for each customer.- Inside each customer,
ORDER BY order_dateaccumulates order by order.
Window frames: ROWS and RANGE
The frame decides which rows the function sees for the current row:
| Frame | Includes |
|---|---|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | first row up to the current row |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | the current row and the two before it |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | previous, current and next |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | the whole partition |
With ORDER BY and no explicit frame, MySQL uses RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE treats rows with the same ORDER BY value as one step, so tied rows get the same running total. Write ROWS when you want strictly row-by-row totals.
SELECT first_name, salary, SUM(salary) OVER (ORDER BY salary) AS range_total, SUM(salary) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_totalFROM employeesWHERE dept_id = 1ORDER BY salary, emp_id;| first_name | salary | range_total | rows_total |
|---|---|---|---|
| Amit | 25000 | 25000 | 25000 |
| Priya | 72000 | 169000 | 97000 |
| Karan | 72000 | 169000 | 169000 |
| Rahul | 98000 | 267000 | 267000 |
| Asha | 125000 | 392000 | 392000 |
- Priya and Karan both earn 72,000.
- With the default
RANGEframe, both tied rows include each other: they both show 169,000. - With
ROWS, the first of the pair shows 97,000 and the second 169,000.
Moving average
A 3-order moving average of order values smooths out spikes:
WITH order_totals AS ( SELECT o.order_id, o.order_date, SUM(oi.quantity * p.price) AS total FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id GROUP BY o.order_id, o.order_date)SELECT order_id, order_date, total, ROUND(AVG(total) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)) AS moving_avg_3FROM order_totalsORDER BY order_date;| order_id | order_date | total | moving_avg_3 |
|---|---|---|---|
| 1001 | 2025-01-05 | 4744 | 4744 |
| 1002 | 2025-01-17 | 8999 | 6872 |
| 1003 | 2025-02-02 | 4298 | 6014 |
| 1004 | 2025-02-14 | 18999 | 10765 |
| 1005 | 2025-03-08 | 3286 | 8861 |
| 1006 | 2025-03-22 | 5497 | 9261 |
| 1007 | 2025-04-10 | 799 | 3194 |
| 1008 | 2025-04-28 | 20996 | 9097 |
| 1009 | 2025-05-15 | 3499 | 8431 |
| 1010 | 2025-06-01 | 1145 | 8547 |
- For each order, the frame holds that order and the two before it.
AVGover the frame is the moving average.- The first two rows have fewer than three orders in the frame, so their average uses one and two orders.
Percent of total and cumulative percent
Which categories make up 80% of revenue (a Pareto analysis)?
WITH cat AS ( SELECT p.category, SUM(oi.quantity * p.price) AS revenue FROM order_items oi JOIN products p ON p.product_id = oi.product_id GROUP BY p.category)SELECT category, revenue, ROUND(100 * revenue / SUM(revenue) OVER (), 1) AS pct, ROUND(100 * SUM(revenue) OVER (ORDER BY revenue DESC) / SUM(revenue) OVER (), 1) AS cumulative_pctFROM catORDER BY revenue DESC;| category | revenue | pct | cumulative_pct |
|---|---|---|---|
| Furniture | 50493 | 69.9 | 69.9 |
| Electronics | 16093 | 22.3 | 92.1 |
| Stationery | 5676 | 7.9 | 100.0 |
Running total without window functions
Before MySQL 8, running totals needed a correlated subquery, which re-adds all earlier rows for every row (slow on big tables):
SELECT o.order_id, o.order_date, (SELECT COUNT(*) FROM orders x WHERE x.order_date <= o.order_date) AS orders_to_dateFROM orders oORDER BY o.order_date;| order_id | order_date | orders_to_date |
|---|---|---|
| 1001 | 2025-01-05 | 1 |
| 1002 | 2025-01-17 | 2 |
| 1003 | 2025-02-02 | 3 |
| 1004 | 2025-02-14 | 4 |
| 1005 | 2025-03-08 | 5 |
| 1006 | 2025-03-22 | 6 |
| 1007 | 2025-04-10 | 7 |
| 1008 | 2025-04-28 | 8 |
| 1009 | 2025-05-15 | 9 |
| 1010 | 2025-06-01 | 10 |
Exercise: Cumulative headcount
List employees by hire_date with a running count of employees hired so far.
| first_name | hire_date | headcount |
|---|---|---|
| Meera | 2015-01-05 | 1 |
| Vikram | 2017-11-20 | 2 |
| Asha | 2018-04-12 | 3 |
| Sneha | 2019-02-25 | 4 |
| Rahul | 2019-07-01 | 5 |
| Kavya | 2020-09-09 | 6 |
| Priya | 2021-01-18 | 7 |
| Neha | 2022-03-14 | 8 |
| Karan | 2022-08-29 | 9 |
| Arjun | 2023-06-05 | 10 |
| Rohan | 2024-01-22 | 11 |
| Amit | 2025-06-02 | 12 |
Hint 1
COUNT(*) OVER (ORDER BY hire_date).
SELECT first_name, hire_date, COUNT(*) OVER (ORDER BY hire_date) AS headcountFROM employeesORDER BY hire_date;| first_name | hire_date | headcount |
|---|---|---|
| Meera | 2015-01-05 | 1 |
| Vikram | 2017-11-20 | 2 |
| Asha | 2018-04-12 | 3 |
| Sneha | 2019-02-25 | 4 |
| Rahul | 2019-07-01 | 5 |
| Kavya | 2020-09-09 | 6 |
| Priya | 2021-01-18 | 7 |
| Neha | 2022-03-14 | 8 |
| Karan | 2022-08-29 | 9 |
| Arjun | 2023-06-05 | 10 |
| Rohan | 2024-01-22 | 11 |
| Amit | 2025-06-02 | 12 |
Quick quiz
1. Which window gives a running total?
ORDER BY in the window makes the sum cumulative.
2. What frame gives a 3-row moving average?
Two previous rows plus the current one make three.
3. Why do two tied rows show the same running total by default?
Use ROWS for a strict row-by-row total.
Running total FAQ
How do I calculate a running total in SQL?
Use SUM(amount) OVER (ORDER BY date_column). Add PARTITION BY to restart the total for each group.
What is the difference between ROWS and RANGE?
ROWS counts physical rows. RANGE works on values, so rows with the same ORDER BY value are treated together.
How do I calculate a moving average in MySQL?
AVG(value) OVER (ORDER BY date ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW) gives an n-row moving average.