Window Frames: Running & Rolling Totals
Control exactly which rows a window calculation sees with ROWS BETWEEN, and build running totals, rolling totals and moving averages.
A partition can be big. Sometimes you only want part of it — "the last two matches" for a form guide, or "from the first match until today" for a season total so far. The frame clause picks that sliding sub-window around the current row.
Definition
A window frame is the subset of rows inside the current partition, measured relative to the current row, that the window function actually uses. It is written as
ROWS BETWEEN <start> AND <end>(orRANGE BETWEEN …) after the ORDER BY inside OVER.
- Inside the partition — a frame never crosses into another player's partition.
- Relative to the current row — as SQL moves from row to row, the frame slides with it.
- Requires ORDER BY — "1 row before" only makes sense when the rows have an order. In SQL Server a frame without ORDER BY is an error.
Syntax
sql
function(expression) OVER (
[PARTITION BY ...]
ORDER BY sort_column
ROWS BETWEEN frame_start AND frame_end
)| Boundary | Meaning |
|---|---|
UNBOUNDED PRECEDING | the first row of the partition |
n PRECEDING | n rows before the current row |
CURRENT ROW | the current row itself |
n FOLLOWING | n rows after the current row |
UNBOUNDED FOLLOWING | the last row of the partition |
The start must come before (or equal) the end. Short form: ROWS UNBOUNDED PRECEDING = ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Default frames — very important
| What you write | Frame SQL uses |
|---|---|
| no ORDER BY in OVER | the entire partition |
| ORDER BY, no frame | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (start of partition → current row) |
Example — three frames, current row = id 3
Using all 12 innings ordered by id (one partition):
sql
SELECT id, runs,
SUM(runs) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS start_to_current,
SUM(runs) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS one_around,
SUM(runs) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS current_to_end,
SUM(runs) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_two
FROM innings;| id | runs | start_to_current | one_around | current_to_end | last_two |
|---|---|---|---|---|---|
| 1 | 82 | 82 | 141 | 645 | 82 |
| 2 | 59 | 141 | 214 | 563 | 141 |
| 3 | 73 | 214 | 177 | 504 | 132 |
| 4 | 45 | 259 | 194 | 431 | 118 |
| 5 | 76 | 335 | 121 | 386 | 121 |
| 6 | 0 | 335 | 102 | 310 | 76 |
| 7 | 26 | 361 | 26 | 310 | 26 |
| 8 | NULL | 361 | 71 | 284 | 26 |
| 9 | 45 | 406 | 118 | 284 | 45 |
| 10 | 73 | 479 | 177 | 239 | 118 |
| 11 | 59 | 538 | 239 | 166 | 132 |
| 12 | 107 | 645 | 166 | 107 | 166 |
Check id 3: start → current = 82 + 59 + 73 = 214; one before → one after = 59 + 73 + 45 = 177; current → end = 73 + 45 + … + 107 = 504. At id 4: running total = 259, last two = 73 + 45 = 118.
Running total vs rolling total
| Running (cumulative) total | Rolling (moving) total | |
|---|---|---|
| Adds | all rows from the first up to the current | a fixed number of recent rows |
| Frame | UNBOUNDED PRECEDING → CURRENT ROW | e.g. 1 PRECEDING → CURRENT ROW |
| Grows forever? | yes | no — old rows drop out |
| IPL meaning | season total so far | form guide: runs in last 2 matches |
Example — running total per player
sql
SELECT id, player, match_date, runs,
SUM(runs) OVER (
PARTITION BY player
ORDER BY match_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM innings;Result: Kohli 82 → 141 → 214, Rohit 45 → 121 → 121, Dhoni 26 → 26 → 71, Buttler 73 → 132 → 239 (same numbers as the previous topic — here the frame is written explicitly).
Example — rolling total of the last 2 matches per player
sql
SELECT id, player, match_date, runs,
SUM(runs) OVER (
PARTITION BY player
ORDER BY match_date
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS rolling_2
FROM innings;| id | player | match_date | runs | rolling_2 |
|---|---|---|---|---|
| 1 | Virat Kohli | 2025-03-22 | 82 | 82 |
| 2 | Virat Kohli | 2025-03-29 | 59 | 141 |
| 3 | Virat Kohli | 2025-04-05 | 73 | 132 |
| 4 | Rohit Sharma | 2025-03-23 | 45 | 45 |
| 5 | Rohit Sharma | 2025-03-30 | 76 | 121 |
| 6 | Rohit Sharma | 2025-04-06 | 0 | 76 |
| 7 | MS Dhoni | 2025-03-24 | 26 | 26 |
| 8 | MS Dhoni | 2025-03-31 | NULL | 26 |
| 9 | MS Dhoni | 2025-04-07 | 45 | 45 |
| 10 | Jos Buttler | 2025-03-25 | 73 | 73 |
| 11 | Jos Buttler | 2025-04-01 | 59 | 132 |
| 12 | Jos Buttler | 2025-04-08 | 107 | 166 |
Step by step: (1) partition by player; (2) order by match date; (3) add only the previous row + the current row. Kohli's third value is 59 + 73 = 132 — his 82 has dropped out of the frame. The first row of each player has no previous row, so it is just its own runs.
Moving average
Swap SUM for AVG and you get a moving average — the classic way to smooth out ups and downs:
sql
ROUND(AVG(runs * 1.0) OVER (
PARTITION BY player
ORDER BY match_date
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
), 1) AS moving_avg_2Buttler: 73 → (73 + 59) ÷ 2 = 66 → (59 + 107) ÷ 2 = 83.
ROWS vs RANGE
- ROWS counts physical rows — "1 PRECEDING" is exactly one row back.
- RANGE works on values of the ORDER BY column — all rows with the same value (ties) are treated as one peer group and included together.
This matters with ties. Ordering by runs (two 45s, two 59s, two 73s):
| id | runs | RANGE (default) | ROWS UNBOUNDED PRECEDING |
|---|---|---|---|
| 6 | 0 | 0 | 0 |
| 7 | 26 | 26 | 26 |
| 4 | 45 | 116 | 71 |
| 9 | 45 | 116 | 116 |
| 2 | 59 | 234 | 175 |
| 11 | 59 | 234 | 234 |
With the default RANGE frame, both 45s show 116 at once. With ROWS, the first 45 shows 71. That is why running totals should use an explicit ROWS frame and a unique ORDER BY.
SQL Server note:
RANGEonly supportsUNBOUNDED PRECEDING,CURRENT ROWandUNBOUNDED FOLLOWING—RANGE BETWEEN 1 PRECEDING …is not allowed. Use ROWS for "n rows" frames. ROWS is also usually faster.
Common mistakes
- ❌ Writing a frame without ORDER BY → ✅ frames require ORDER BY in OVER.
- ❌ Using a frame with ROW_NUMBER, RANK, NTILE, LAG or LEAD → ✅ frames are not allowed for those functions.
- ❌ Relying on the default RANGE frame when the ORDER BY column has ties → ✅ write
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWexplicitly. - ❌ Putting the end before the start (
ROWS BETWEEN CURRENT ROW AND 1 PRECEDING) → ✅ the start boundary must come first.
Interview tip
Q: How do you calculate a 3-match moving average in SQL?
A: AVG(runs) OVER (PARTITION BY player ORDER BY match_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) — the current match plus the two before it.
Key points to remember
- A frame is the part of the partition, relative to the current row, that the function uses: ROWS BETWEEN start AND end.
- Boundaries: UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, UNBOUNDED FOLLOWING.
- No ORDER BY → frame = entire partition. ORDER BY without a frame → RANGE from the first row to the current row.
- Running total = UNBOUNDED PRECEDING → CURRENT ROW; rolling total = a fixed number of rows, e.g. 1 PRECEDING → CURRENT ROW.
- AVG with a rolling frame gives a moving average.
- ROWS counts physical rows; RANGE groups tied ORDER BY values together — prefer ROWS for running totals.
- Frames need ORDER BY and are not allowed with ranking functions or LAG/LEAD.
Try it yourself
Frame boundaries compared
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
Running vs rolling total per player
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
ROWS vs RANGE with ties
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
Practice: 3-match moving average for the season
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
Revision Flashcards
Tap a card to flip it and see the answer.
