Mastering SQL Window Functions for Running Totals and Moving Averages
The Problem That Keeps Coming Back
Every reporting dashboard I’ve built eventually asks for a running total or a moving average. Early in my career I would reach for a self‑join or a correlated subquery, only to watch the query plan explode on anything beyond a few thousand rows. Window functions changed that. They let you express the calculation declaratively, keep the plan simple, and make the intent obvious to the next developer who reads the code.
A Real‑World Scenario
Imagine a SaaS product that tracks daily active users (DAU). The product team wants a 7‑day moving average to smooth out weekend spikes, and the finance team needs a cumulative sign‑up count for the year‑to‑date board deck. Both metrics come from the same events table:
CREATE TABLE events (
event_date DATE NOT NULL,
event_type TEXT NOT NULL, -- 'signup' or 'login'
user_id BIGINT NOT NULL
);
We need two columns in the result set: running_signups (cumulative count of signups) and mavg_dau (7‑day moving average of distinct logins).
The Window‑Function Solution
WITH daily AS (
-- Aggregate to one row per day
SELECT
event_date,
COUNT(*) FILTER (WHERE event_type = 'signup') AS signups,
COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'login') AS dau
FROM events
GROUP BY event_date
),
calc AS (
SELECT
event_date,
-- Running total of signups
SUM(signups) OVER (ORDER BY event_date
ROWS UNBOUNDED PRECEDING) AS running_signups,
-- 7‑day moving average of DAU
AVG(dau) OVER (ORDER BY event_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS mavg_dau
FROM daily
)
SELECT event_date,
running_signups,
ROUND(mavg_dau, 2) AS mavg_dau
FROM calc
ORDER BY event_date;
Why This Works
- Single pass over the data. The CTE
dailyreduces the raw events to one row per day. The window functions then operate on that tiny result set, not on the millions of raw rows. - Declarative framing.
ROWS UNBOUNDED PRECEDINGtells the engine “start at the first row and go to the current row”.ROWS BETWEEN 6 PRECEDING AND CURRENT ROWis a sliding window of exactly seven days. No correlated subqueries, no self‑joins. - Deterministic ordering. The
ORDER BY event_dateguarantees the same result regardless of physical storage order. - Performance. Modern planners (PostgreSQL, SQL Server, Oracle, Snowflake, BigQuery) implement window aggregates with a single sort + linear scan, O(N log N) for the sort and O(N) for the window pass.
Common Pitfalls and How to Avoid Them
Pitfall: Forgetting the frame clause defaults to
RANGE UNBOUNDED PRECEDING, which treats duplicateORDER BYvalues as a single group. With dates that’s usually fine, but with timestamps you can get surprising results.
Fix: be explicit. Use ROWS when you want a physical row count, RANGE when you want a logical value range.
Pitfall: Applying window functions directly on the raw
eventstable without pre‑aggregation.
Fix: aggregate first. Window functions on millions of rows with COUNT(DISTINCT ...) inside the frame are notoriously slow because the distinct operation must be recomputed for each frame.
Extending the Pattern
Once you’re comfortable, the same skeleton solves many “trend” questions:
- Year‑over‑year growth:
LAG(signups, 365) OVER (ORDER BY event_date)gives you the same day last year. - Percent of total:
signups * 100.0 / SUM(signups) OVER ()yields a running percentage. - Gap detection:
event_date - LAG(event_date) OVER (ORDER BY event_date) > INTERVAL '1 day'flags missing days.
When Not to Use Window Functions
If the result set must be materialized for a very large number of partitions (e.g., per‑user running totals across billions of rows), consider a materialized view or a streaming aggregation layer. Window functions shine for reporting‑scale data — thousands to low‑millions of rows — where the sort fits in memory.
Final Thought
Window functions are one of those SQL features that feel like a secret weapon once you internalize the frame clause syntax. They turn imperative “loop‑and‑accumulate” logic into a single, readable expression that the optimizer can execute efficiently. Next time you reach for a correlated subquery to compute a running total, pause and ask yourself: “Can I frame this as a window?” The answer is almost always yes, and your future self will thank you for the cleaner plan.