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

Skip to content