Window Frames: ROWS BETWEEN and RANGE BETWEEN
The frame is the part of the window a function like SUM or AVG uses for the current row.
Set it with ROWS BETWEEN (counting rows) or RANGE BETWEEN (comparing values).
Syntax
Order of execution: FROM → WHERE → GROUP BY → window functions (each row's frame) → SELECT → ORDER BY
CodeSELECT column, SUM(column) OVER (ORDER BY sort_column ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_sum, SUM(column) OVER (ORDER BY date_column RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) AS last_7_days FROM table_name;
| Frame | Rows it covers |
|---|---|
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | this row and the two rows before it |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | the row before, this row and the row after |
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW | rows whose date is up to 6 days before this row's, or the same |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | the whole partition |
no frame, with ORDER BY | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: a running total |
RANGE with a distance such as 6 days works in PostgreSQL, MySQL (INTERVAL 6 DAY) and Oracle
(INTERVAL '6' DAY). SQL Server allows RANGE only with UNBOUNDED and CURRENT ROW.
Examples
Running total
The default frame with ORDER BY adds everything up to the current row.
CodeSELECT month, revenue, SUM(revenue) OVER (ORDER BY month) AS running_revenue FROM v_monthly_sales WHERE month >= DATE '2024-01-01' AND month < DATE '2025-01-01' ORDER BY month;
A moving window of rows
Each row adds itself and the row before: a two-month moving total.
CodeSELECT month, revenue, SUM(revenue) OVER (ORDER BY month ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS two_month_total FROM v_monthly_sales WHERE month >= DATE '2024-01-01' AND month < DATE '2025-01-01' ORDER BY month;
Ties: ROWS vs RANGE
Several orders on one date sort equally. The default RANGE frame adds all of that date's
orders at once; ROWS adds one row at a time.
CodeSELECT order_date, order_id, COUNT(*) OVER (ORDER BY order_date) AS by_range, COUNT(*) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows FROM orders WHERE order_date BETWEEN DATE '2025-09-10' AND DATE '2025-09-14' ORDER BY order_date, order_id;
A range of dates
RANGE with an interval covers a span of time, however many rows fall in it.
CodeSELECT order_date, order_id, total_amount, SUM(total_amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) AS last_7_days FROM orders WHERE customer_id = 3 ORDER BY order_date, order_id;
At the edges
Near the start there are fewer rows before, so the frame is smaller.
CodeSELECT month, revenue, ROUND(AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING), 2) AS centred_avg FROM v_monthly_sales WHERE month >= DATE '2024-01-01' AND month < DATE '2024-07-01' ORDER BY month;
Remember
- With
ORDER BYand no frame, the frame is a running one that includes every row tied with the current row. ROWScounts rows;RANGEcompares values, so tied rows and missing dates behave differently.RANGEwith an interval needs a singleORDER BYcolumn of a date or number type.- Frames shrink at the edges of the partition; the first rows have fewer rows before them.
- Frames matter for aggregates,
FIRST_VALUE,LAST_VALUEandNTH_VALUE; ranking functions,LAGandLEADignore them.
Practice
- Running Revenue for 2025 (Medium)
- Three-Month Moving Average (Medium)
- Revenue in the Last 30 Days (Hard)