Skip to content
datarekha
SQL Medium Asked at StripeAsked at ShopifyAsked at Netflix

How would you calculate a 7-day moving average of daily sales, and what frame clause is needed?

The short answer

Use AVG() with a ROWS frame specifying 6 PRECEDING to current row — this captures exactly 7 physical rows regardless of date gaps. RANGE with INTERVAL '6 days' PRECEDING is the alternative when you need a true calendar window, but it requires at most one row per date and may include fewer than 7 rows if days are missing.

How to think about it

This question is really about frame clauses — not whether you know AVG() OVER (ORDER BY ...) exists. The frame is the subtle part: it controls which rows feed the average, and getting it wrong by one row turns a “7-day” average into an 8-day one.

A window function computes over a frame of rows, and two frame modes matter here:

  • ROWS counts physical rows around the current one, ignoring the actual ORDER BY values.
  • RANGE counts rows whose ORDER BY value falls within a span of the current row’s value.

For a 7-day moving average, ROWS BETWEEN 6 PRECEDING AND CURRENT ROW means “the 6 rows before this one, plus this one — 7 total.” Note 6 PRECEDING, not 7: six before plus the current row is seven.

A worked example — watch the window fill

Ten days of sales, with a spike on day 5. Alongside the average, COUNT(*) over the same frame exposes the “ramp-up” — the window holds fewer than 7 rows until day 7:

SELECT sale_date, daily_revenue,
       ROUND(AVG(daily_revenue) OVER (
         ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ), 0) AS ma_7d,
       COUNT(*) OVER (
         ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS window_size
FROM daily_sales
ORDER BY sale_date;
sale_datedaily_revenuema_7dwindow_size
2024-01-0110001000.01
2024-01-0212001100.02
2024-01-039001033.03
2024-01-0411001050.04
2024-01-0535001540.05
2024-01-0610501458.06
2024-01-079801390.07
2024-01-0811501411.07
2024-01-0910201386.07
2024-01-1013001443.07

Two things to read off this. First, window_size climbs 1 → 7 over the opening week and then stays at 7 — that’s the frame holding exactly seven rows once enough history exists. Second, the day-5 spike of 3500 lifts the average to 1540 but doesn’t dominate it the way a raw reading would; by day 10 it has slid out of the window entirely. (ROUND(..., 0) returns a float in SQLite, hence the trailing .0.)

Why ROWS, not RANGE

RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW includes every row whose date is within the last 6 calendar days. If the data has gaps — no sales on weekends — those missing dates shrink the denominator unpredictably, so a “7-day” average might quietly average 5 rows. ROWS is deterministic: always exactly N physical rows. For a per-product average, add PARTITION BY product_id and the window resets at each product boundary, so one product’s rows never bleed into another’s.

Learn it properly Window functions

Keep practising

All SQL questions

Explore further