P11 · SQL & Querying
SQL rolling windows — ROWS vs RANGE frames
How SQL window frames build running totals — and why ROWS (count rows) and RANGE (count time) quietly disagree the moment your dates have gaps.
Start with the basics
A running total or rolling average is a window function with a frame — a rule for which rows around the current one the sum should see.
The frame can be counted two ways. ROWS counts a fixed number of rows back; RANGE counts by the ordering value itself, so 'the last 7 days' means days, not rows.
Most of the time the data has one row per day and the two agree. The traps all show up the moment the rows are uneven — gaps, ties, or several rows on one day.
One that works
| day | amount |
|---|---|
| d1 | 10 |
| d2 | 30 |
SELECT day, SUM(amount) OVER (
ORDER BY day
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS rolling
FROM txns
ORDER BY day;| day | rolling |
|---|---|
| d1 | 10 |
| d2 | 40 |
Each row's rolling value sums itself and the row before it.
A window frame says which rows around the current one an aggregate sees — and ROWS counts rows while RANGE counts values.
When to use it
- Reach for a frame when you want a running total, a moving average, or a cumulative sum.
- Use ROWS for 'the last N rows'; use RANGE with an interval for 'the last N days'.
- If your dates have gaps or several rows per day, choose the frame deliberately — the two stop agreeing there.
That choice — count rows or count time — is where a rolling total quietly lies. The two agree only when the data is perfectly even; the day a gap appears, ROWS reaches back too far. Here is the day that bites.
See it happen
The rolling total that reaches too far — ROWS vs RANGE
You want a rolling 7-day spend total. One customer has a gap in their transactions. Watch how counting rows pulls in a transaction that is actually 8 days old.
| txn_date | amount |
|---|---|
| 2024-01-01 | 10 |
| 2024-01-09 | 30 |
| 2024-01-10 | 5 |
ROWS 6 PRECEDING — counts rows
SELECT txn_date, SUM(amount) OVER (
ORDER BY txn_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS rolling
FROM txns ORDER BY txn_date;| txn_date | rolling |
|---|---|
| 2024-01-01 | 10 |
| 2024-01-09 | 40 |
| 2024-01-10 | 45 |
RANGE 6 days — counts time
SELECT txn_date, SUM(amount) OVER (
ORDER BY txn_date
RANGE BETWEEN INTERVAL '6 days'
PRECEDING AND CURRENT ROW
) AS rolling
FROM txns ORDER BY txn_date;| txn_date | rolling |
|---|---|
| 2024-01-01 | 10 |
| 2024-01-09 | 30 |
| 2024-01-10 | 35 |
Wrong by $10, no error raised, and every calendar gap makes it worse.
Walk the full example in the unit →The rest of the family
More ways a running total goes wrong
Ordering with no explicit frame includes every row that ties on the ordering value, so a running total jumps on tied keys; spelling out a rows-based frame is the fix.
A rolling metric over dates with missing days needs a complete calendar or a per-day pre-aggregation to be correct.
The opening rows of a moving average sit on incomplete windows — declare them or exclude them rather than reporting them as full.
A running total restarts at each partition boundary; omit the partition and one entity's total runs straight into the next.
With ordering and no frame clause, the window ends at the current row, so asking for the last value returns the current row's value, not the group's last.
Excluding the current row or its ties fine-tunes exactly which frame rows feed the aggregate.
Defining a window once and reusing it by name stops two copies of the same window from silently drifting apart.
Self-check
How to spot it in your own SQL
- A rolling total looks slightly high, and worse where the dates have gaps.
- A running total jumps at rows that share a value.
- One entity's running total seems to continue into the next.
- A moving average's first few points look wrong.
What people miss
Running totals feel solved once the query runs, but the frame is silent about what it includes. ROWS and RANGE agree on clean data and diverge on real data — exactly when nobody is checking.
Go deeper
Ask Colearn
Instant answers, grounded in the same verified material the diagnostic grades against.
What's the difference between ROWS and RANGE in a window frame?
ROWS counts a fixed number of rows back from the current one. RANGE counts by the ordering value — so with dates, 'RANGE 6 days preceding' means the last week of calendar time, however many rows that is.
For a rolling 7-day metric use RANGE with an interval; ROWS only matches it when there is exactly one row per day.
from the unit →Why does my running total jump on tied rows?
With just ORDER BY and no explicit frame, the window defaults to including every row that ties on the ordering value — so tied rows all get the same, already-summed total.
Spell out an explicit rows-based frame to get a true one-row-at-a-time running sum.
from the unit →Why does my running total run into the next customer?
A running total only restarts where you tell it to. Without PARTITION BY, it treats all rows as one long sequence, so one customer's total continues into the next.
Add PARTITION BY the entity so the total resets per group.
from the unit →Why does LAST_VALUE return the current row instead of the last?
With ordering and no frame clause, the window ends at the current row, so 'last value' is just the current row's value.
Extend the frame to the end of the partition to get the actual last value.
from the unit →That’s the free taste