Skip to main content

Running Totals and Moving Averages

Window functions with an ORDER BY inside OVER compute cumulative (running) values by default. You can also control exactly which rows to include using a window frame.

Prior lesson

This lesson builds on Window Functions. The sample dataset is defined in the Introduction to SQL lesson.

Window frames

A frame defines the subset of rows in the window used for each calculation. Without one, SUM() OVER (ORDER BY ...) uses the default frame: all rows from the start of the partition up to (and including) the current row.

FUNCTION() OVER (
PARTITION BY col
ORDER BY col
ROWS BETWEEN start AND end
)

Common frame boundaries:

BoundaryMeaning
UNBOUNDED PRECEDINGFirst row of the partition
N PRECEDINGN rows before the current row
CURRENT ROWThe current row
N FOLLOWINGN rows after the current row
UNBOUNDED FOLLOWINGLast row of the partition

Examples

Example 1: Running total of revenue

SELECT
order_id,
order_date,
total_amount,
SUM(total_amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY order_date;

Result:

order_idorder_datetotal_amountrunning_total
12024-01-05999.00999.00
22024-01-1059.981058.98
32024-01-15349.001407.98
42024-02-01796.002203.98
52024-02-1049.902253.88
62024-03-01349.002602.88
72024-03-15999.003601.88
82024-04-0189.973691.85
92024-04-10999.004690.85
102024-05-0124.954715.80

Example 2: Running total per customer

Add PARTITION BY to reset the running total for each customer.

SELECT
order_id,
customer_id,
order_date,
total_amount,
SUM(total_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders
ORDER BY customer_id, order_date;

Result (customer 1):

order_idcustomer_idorder_datetotal_amountcustomer_running_total
112024-01-05999.00999.00
212024-01-1059.981058.98
612024-03-01349.001407.98

Example 3: 3-period moving average

Use a sliding frame of the current row plus the 2 preceding rows.

WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
month,
revenue,
ROUND(
AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS moving_avg_3m
FROM monthly_revenue
ORDER BY month;

Result:

monthrevenuemoving_avg_3m
2024-01-011407.981407.98
2024-02-01845.901126.94
2024-03-011348.001200.63
2024-04-011088.971094.29
2024-05-0124.95820.64

January and February use fewer rows (they don't have 2 preceding months), so the frame shrinks automatically.

Example 4: Percentage of total

Divide each order's amount by the grand total using OVER () with no frame.

SELECT
order_id,
total_amount,
ROUND(
100.0 * total_amount / SUM(total_amount) OVER (),
1
) AS pct_of_total
FROM orders
ORDER BY total_amount DESC;

Common Mistake

ROWS BETWEEN vs RANGE BETWEEN behave differently on ties

ROWS BETWEEN counts physical rows; RANGE BETWEEN counts logical values. On a date column with multiple orders on the same day, RANGE BETWEEN ... CURRENT ROW includes all rows with the same date value, not just the current physical row.

For running totals, ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is almost always what you want - it's deterministic and avoids surprises with ties.

Practice

Write a query that calculates, for each order sorted by order_date, the order_id, total_amount, and a 2-period moving average of total_amount (current row plus 1 preceding), called moving_avg_2.

Show answer
SELECT
order_id,
order_date,
total_amount,
ROUND(
AVG(total_amount) OVER (
ORDER BY order_date
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
),
2
) AS moving_avg_2
FROM orders
ORDER BY order_date;
Donate to this project