Hook: Interviewers love this question because it looks simple, but it quietly tests whether you really understand window frames, ordering, and the difference between a trailing average and a running average.
Question: How do you calculate a moving average in SQL?
Answer: A moving average is a rolling average over a fixed set of ordered rows, such as the last 3 sales or last 7 days. In SQL, you usually use a window function like AVG(... ) OVER (...) with a frame such as ROWS BETWEEN 2 PRECEDING AND CURRENT ROW. The key idea is that the query keeps each row in the output while looking backward over a small window of nearby rows.
Interview-Ready Answer: I would calculate it with a window function. For example, I’d use AVG(value) OVER (ORDER BY date_column ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) for a 7-row trailing average, and I’d add PARTITION BY if I needed it per product or per customer. One important detail is that I use ROWS when I want an exact number of rows, because the default ordered window frame can behave like a running average instead of a true moving average.
A window function is a function that looks at related rows without collapsing them into one result row. A frame is the exact subset of rows the function can see for the current row. For a moving average, that frame usually slides forward one row at a time.
sold_on or created_at.AVG, is computed only over that frame.A lot of candidates say “moving average” when they really mean “running average.” A running average includes everything from the start up to the current row. A moving average includes only a fixed-size neighborhood. A centered average looks both backward and forward, which is useful for charts but awkward for live systems because future rows are not available yet.
| Type | Frame idea | Typical use |
|---|---|---|
| Moving | Last N rows | Live smoothing |
| Running | All rows so far | Cumulative trend |
| Centered | Before and after | Chart smoothing |
ROWS vs RANGEThis is the main gotcha. ROWS counts physical rows. RANGE groups rows with the same sort key value, which means duplicates can change the result in ways that surprise people. If you want a true 7-row or 30-row moving average, use ROWS and make the order deterministic with a tie-breaker when needed.
| Frame | Meaning | Gotcha |
|---|---|---|
| ROWS | Counts rows | Best for true moving avg |
| RANGE | Groups peers | Duplicates can widen frame |
The expensive part is usually the sort on the partition and order keys. After that, the engine scans each partition and applies the window aggregate. In practice, the overall cost is often dominated by sorting, so think roughly O(n log n) for the sort plus a linear pass, although many engines optimize bounded frames well. A small frame like 7 or 30 rows is usually cheap; very large partitions can still spill to disk if memory is tight.
If you are using PostgreSQL, the sort may spill when work memory is too small. In MySQL, window functions are available starting in 8.0; older versions usually need a self-join, which is slower and harder to read. The same idea exists across modern SQL engines, but the exact syntax and optimizer behavior can differ a bit.
AVG ignores NULLs, so the result may be based on fewer points than you expect.Memory note: think of a moving average like a sliding camera window over a train. You are not filming the whole train at once; you are only looking at the few cars currently in view.
Real-World Story: Imagine a checkout service at an e-commerce company. The ops team tracks a 7-row moving average of daily order volume and payment failures so they can tell whether a spike is real or just noise. The dashboard is refreshed every morning, and product managers use it to decide whether a promotion is helping or hurting conversion.
Now picture the bug: an engineer writes AVG(order_count) OVER (ORDER BY day) and assumes it is a moving average. Because the default ordered frame behaves like a running average and duplicate dates are treated as peers, the chart looks flatter than it should and then jumps in strange places. Users start asking why Friday looks identical to Thursday, support tickets mention inconsistent totals, and the log line shows the query returning the same value for multiple rows on the same date. The outage is not a crash; it is a trust problem, because the team makes bad decisions from misleading numbers.
The fix is simple but important: use an explicit ROWS frame, add a tie-breaker to the ORDER BY, and if the business wants calendar days instead of rows, build the data onto a complete date spine first.
DROP TABLE IF EXISTS daily_sales;
CREATE TABLE daily_sales (
sale_id INTEGER PRIMARY KEY,
sold_on DATE NOT NULL,
revenue NUMERIC(10,2)
);
INSERT INTO daily_sales (sale_id, sold_on, revenue) VALUES
(1, DATE '2026-01-01', 100.00),
(2, DATE '2026-01-02', 120.00),
(3, DATE '2026-01-03', NULL),
(4, DATE '2026-01-04', 150.00),
(5, DATE '2026-01-05', 130.00),
(6, DATE '2026-01-05', 170.00); -- duplicate date: good test for deterministic ordering
SELECT
sale_id,
sold_on,
revenue,
-- This shows the pitfall: with ORDER BY only, many engines use the default frame,
-- which behaves like a running average rather than a true 3-row moving average.
ROUND(AVG(revenue) OVER (ORDER BY sold_on), 2) AS default_running_avg,
-- ROWS counts physical rows, so this is a real trailing 3-row moving average.
-- The sale_id tie-breaker makes the order stable when two rows share the same date.
ROUND(
AVG(revenue) OVER (
ORDER BY sold_on, sale_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS moving_avg_3_rows,
-- COUNT(revenue) shows how many non-NULL values actually contributed.
COUNT(revenue) OVER (
ORDER BY sold_on, sale_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS non_null_points
FROM daily_sales
ORDER BY sold_on, sale_id;Follow-up & Tricky Questions:
PARTITION BY product_id or PARTITION BY store_id so each group gets its own sliding window.ROWS and RANGE? ROWS counts rows, while RANGE groups peers with equal sort values; for moving averages, ROWS is usually the safer choice.ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING. That is useful for analysis, but not for real-time reporting because it needs future rows.WHERE? Not directly, because window functions are evaluated after WHERE. Use a CTE or subquery, then filter in the outer query.Tricky / Gotcha Questions:
AVG(x) OVER (ORDER BY t) not behave like a true moving average? Because the default ordered frame is usually a running-style frame, not a fixed trailing row count. You must spell out the frame explicitly to get a true moving average.AVG ignores NULLs, so they do not contribute to the numerator or denominator. If you need to treat NULL as zero, use COALESCE intentionally.Common Mistakes:
ROWS BETWEEN ... when you want a fixed-size moving average.ORDER BY. Correction: add a stable key like sale_id so rows with the same timestamp are processed predictably.AVG skips NULLs; use COUNT(column) if you want to see how many points contributed.Memory Hook: Picture a conveyor belt with a small basket on top: every new row drops one old row off the back and adds the newest row to the front. That is a moving average; a running average is the basket that never drops anything.
Cheat Sheet:
AVG(value) OVER (ORDER BY time ROWS BETWEEN N PRECEDING AND CURRENT ROW) is the standard trailing moving average pattern.PARTITION BY to compute one moving average per group.ROWS counts rows; RANGE groups equal sort values.AVG ignores NULLs.Practice Tasks: