Mastering SQL Window Functions for Running Totals and Moving Averages
Why Window Functions Matter
When I first started writing analytical queries, I reached for sub‑queries and self‑joins to compute cumulative sums. The syntax was verbose, the execution plans were messy, and maintenance became a nightmare as the data grew. Then I discovered window functions — a single, declarative way to express calculations over a set of rows related to the current row. They keep the query readable, let the optimizer do its job, and scale far better than the old patterns.
A Real‑World Scenario: Daily Sales Dashboard
Imagine a SaaS product that logs every transaction in a sales table. The product team wants a dashboard showing:
- Running total of revenue per day.
- 7‑day moving average of daily revenue.
- Year‑to‑date (YTD) revenue for each day.
All of these need to be computed on the fly for any date range the user selects. Writing three separate sub‑queries would duplicate scans of the same table. Window functions let us calculate everything in one pass.
The Production‑Ready Query
-- sales table: id, sale_date (date), amount (numeric)
WITH daily AS (
-- Aggregate to one row per day first
SELECT
sale_date,
SUM(amount) AS daily_revenue
FROM sales
WHERE sale_date BETWEEN $1 AND $2 -- parameterized range
GROUP BY sale_date
),
calc AS (
SELECT
sale_date,
daily_revenue,
-- Running total ordered by date
SUM(daily_revenue) OVER (ORDER BY sale_date
ROWS UNBOUNDED PRECEDING) AS running_total,
-- 7‑day moving average (includes current day)
AVG(daily_revenue) OVER (ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d,
-- Year‑to‑date total using PARTITION BY year
SUM(daily_revenue) OVER (PARTITION BY EXTRACT(YEAR FROM sale_date)
ORDER BY sale_date
ROWS UNBOUNDED PRECEDING) AS ytd_revenue
FROM daily
)
SELECT
sale_date,
daily_revenue,
running_total,
ROUND(moving_avg_7d, 2) AS moving_avg_7d,
ytd_revenue
FROM calc
ORDER BY sale_date;
Breaking Down the Syntax
The OVER clause defines the window — the set of rows the function sees. Three pieces control that window:
- PARTITION BY – splits the data into independent groups (here, each calendar year).
- ORDER BY – determines the logical order inside each partition.
- FRAME –
ROWS BETWEEN …or the defaultRANGE UNBOUNDED PRECEDINGlimits the rows actually considered.
In the example:
SUM(...) OVER (ORDER BY sale_date ROWS UNBOUNDED PRECEDING)adds every prior day’s revenue, giving a true running total.AVG(...) OVER (ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)looks at the current day plus the six preceding days — exactly a 7‑day window.SUM(...) OVER (PARTITION BY EXTRACT(YEAR FROM sale_date) ORDER BY sale_date ROWS UNBOUNDED PRECEDING)resets the accumulation at each new year, producing YTD figures.
Performance Considerations
Window functions are executed after the
WHEREandGROUP BYphases, so filtering early (theWHERE sale_date BETWEEN …in the CTE) dramatically reduces the rows the window sees.
Indexes on sale_date (or a composite index covering sale_date, amount) let the database satisfy the GROUP BY and the ordered window without a full sort. On PostgreSQL, a B‑tree index on sale_date is usually enough; on SQL Server, a covering index INCLUDE (amount) avoids a key lookup.
Common Pitfalls and How to Avoid Them
- Using
RANGEinstead ofROWSfor date‑based windows.RANGEworks on the logical value of the ORDER BY column and can produce surprising results when there are duplicate dates.ROWScounts physical rows, which is deterministic. - Forgetting the frame clause. The default frame is
RANGE UNBOUNDED PRECEDING AND CURRENT ROW. For a moving average you almost always need an explicitROWS BETWEEN n PRECEDING AND CURRENT ROW. - Mixing partitions with non‑deterministic ordering. If two rows share the same
sale_date, add a tie‑breaker (e.g.,id) to theORDER BYso the window frame is stable.
Extending the Pattern
Once you’re comfortable, the same technique solves many other problems:
- Gap detection –
LAG(sale_date) OVER (ORDER BY sale_date)lets you spot missing days. - Top‑N per group –
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC)filters the best sellers per category. - Percentile calculations –
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) OVER ()gives a median without a separate aggregation pass.
Final Thoughts
Window functions turned a handful of tangled sub‑queries into a single, maintainable statement that the optimizer can parallelize. The next time you reach for a correlated sub‑query to compute a cumulative metric, pause and ask whether an OVER clause can do the job. In my experience, the answer is almost always yes — and the resulting code is easier to read, faster to run, and simpler to hand off to the next developer.