Chapter 3: Aggregate Functions and Window Functions
You already know SUM, AVG, and COUNT as aggregate functions. But when you add OVER(), they become window functions. Instead of collapsing rows, they calculate running totals, cumulative counts, and moving averages — while keeping every row visible.
A running total is like a bank account balance. Each row shows the balance after that transaction. The first row is just the first amount. The second row is the first plus the second. And so on.
-- Running total of daily revenue
SELECT
order_date,
SUM(total_amount) AS daily_revenue,
SUM(SUM(total_amount)) OVER(ORDER BY order_date) AS cumulative_revenue
FROM orders
GROUP BY order_date
ORDER BY order_date;
-- Result:
-- order_date | daily_revenue | cumulative_revenue
-- 2024-01-01 | 15000 | 15000
-- 2024-01-02 | 18000 | 33000
-- 2024-01-03 | 12000 | 45000
-- 2024-01-04 | 22000 | 67000
-- Per-customer running total of spending
SELECT
customer_id,
order_id,
order_date,
total_amount,
SUM(total_amount) OVER(
PARTITION BY customer_id
ORDER BY order_date
) AS customer_running_total
FROM orders
ORDER BY customer_id, order_date;-- Cumulative order count per day
SELECT
order_date,
COUNT(*) AS daily_orders,
SUM(COUNT(*)) OVER(ORDER BY order_date) AS total_orders_so_far
FROM orders
GROUP BY order_date
ORDER BY order_date;
-- QA scenario: Verify user signup growth
SELECT
DATE_TRUNC('month', created_at) AS signup_month,
COUNT(*) AS new_users,
SUM(COUNT(*)) OVER(ORDER BY DATE_TRUNC('month', created_at)) AS total_users
FROM users
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY signup_month;A moving average smooths out daily noise. Instead of looking at one day's revenue, you look at the average of the last 7 days. This is how finance dashboards work. The frame clause (ROWS BETWEEN ... AND ...) defines the window size.
-- 7-day moving average of daily revenue
SELECT
order_date,
SUM(total_amount) AS daily_revenue,
ROUND(
AVG(SUM(total_amount)) OVER(
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2
) AS seven_day_avg
FROM orders
GROUP BY order_date
ORDER BY order_date;
-- 3-day moving average of API response times
SELECT
log_date,
ROUND(AVG(response_time_ms), 2) AS daily_avg_ms,
ROUND(
AVG(AVG(response_time_ms)) OVER(
ORDER BY log_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2
) AS three_day_moving_avg
FROM api_logs
GROUP BY log_date
ORDER BY log_date;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW means "this row plus the 6 rows before it" — that is 7 rows total. For a 30-day moving average, use 29 PRECEDING. The frame clause is optional — without it, the default is all rows from the start up to the current row.
| Pattern | Syntax | Use Case |
|---|---|---|
| Running total | SUM() OVER(ORDER BY date) | Cumulative revenue, bank balance |
| Cumulative count | COUNT() OVER(ORDER BY date) | Total signups over time |
| Moving average | AVG() OVER(ORDER BY date ROWS BETWEEN N PRECEDING AND CURRENT ROW) | 7-day avg revenue, trend smoothing |
| Grand total per row | SUM() OVER() | Each row's % of total |
The default frame for OVER(ORDER BY ...) is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, not ROWS. This means tied rows get the same cumulative value. If you want strict row-by-row accumulation, explicitly use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Key Point: SUM/AVG/COUNT with OVER() become window functions. Running totals, cumulative counts, and moving averages — all without collapsing rows. The frame clause controls how many rows the window includes.