Window Frames: ROWS and RANGE
Window frames let you define exactly which rows participate in each calculation. Need a 7-day moving average? A running total? Sum of previous 3 rows? Frames give you precise control.
The Frame Clause Syntax
function(...) OVER (
PARTITION BY ...
ORDER BY ...
frame_type BETWEEN frame_start AND frame_end
)
Frame Types
- ROWS
- RANGE
- GROUPS (PostgreSQL 11+)
Frame Boundaries
- UNBOUNDED PRECEDING
- n PRECEDING
- CURRENT ROW
- n FOLLOWING
- UNBOUNDED FOLLOWING
Default Frame Behavior
- Without ORDER BY: RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (entire partition)
- With ORDER BY: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (running calculation)
ROWS vs RANGE
ROWS: Physical Row Count
ROWS counts actual rows regardless of values.
SELECT
date,
revenue,
SUM(revenue) OVER (
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as sum_3_rows
FROM daily_sales;
| date | revenue | sum_3_rows | rows included |
|---|---|---|---|
| Jan 1 | 100 | 100 | [Jan 1] |
| Jan 2 | 150 | 250 | [Jan 1, Jan 2] |
| Jan 3 | 120 | 370 | [Jan 1, Jan 2, Jan 3] |
| Jan 4 | 200 | 470 | [Jan 2, Jan 3, Jan 4] |
| Jan 5 | 180 | 500 | [Jan 3, Jan 4, Jan 5] |
RANGE: Value-Based Boundaries
RANGE uses the ORDER BY column's values, not row count.
-- Include all rows with the same date (handles ties)
SELECT
date,
sale_id,
amount,
SUM(amount) OVER (
ORDER BY date
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) as running_total
FROM sales;
| date | sale_id | amount | running_total (RANGE) |
|---|---|---|---|
| Jan 1 | S001 | 100 | 250 |
| Jan 1 | S002 | 150 | 250 |
| Jan 2 | S003 | 200 | 450 |
Notice: Both Jan 1 rows show 250 because RANGE includes all rows with the same date value.
Moving Averages
7-Day Moving Average
SELECT
date,
revenue,
ROUND(AVG(revenue) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) as avg_7_day
FROM daily_sales;
| date | revenue | avg_7_day |
|---|---|---|
| Day 1 | 1000 | 1000.00 |
| Day 2 | 1200 | 1100.00 |
| ... | ... | ... |
| Day 7 | 1500 | 1257.14 |
| Day 8 | 1100 | 1285.71 |
Centered Moving Average
-- 5-day centered average (2 before, current, 2 after)
SELECT
date,
value,
AVG(value) OVER (
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING
) as centered_avg
FROM timeseries;
Common Frame Patterns
| Pattern | Frame Clause | Use Case |
|---|---|---|
| Running total | UNBOUNDED PRECEDING AND CURRENT ROW | Cumulative sum/count |
| Entire partition | UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | Partition totals, LAST_VALUE |
| Moving average | N PRECEDING AND CURRENT ROW | Trailing N-period average |
| Centered | N PRECEDING AND N FOLLOWING | Smoothing, outlier detection |
| Look ahead | CURRENT ROW AND N FOLLOWING | Forward-looking aggregates |
Real-World: Stock Price Analysis
SELECT
date,
close_price,
-- 20-day moving average
ROUND(AVG(close_price) OVER (
ORDER BY date
ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
), 2) as ma_20,
-- 50-day moving average
ROUND(AVG(close_price) OVER (
ORDER BY date
ROWS BETWEEN 49 PRECEDING AND CURRENT ROW
), 2) as ma_50,
-- Highest in last 52 weeks
MAX(close_price) OVER (
ORDER BY date
ROWS BETWEEN 251 PRECEDING AND CURRENT ROW
) as high_52w,
-- Lowest in last 52 weeks
MIN(close_price) OVER (
ORDER BY date
ROWS BETWEEN 251 PRECEDING AND CURRENT ROW
) as low_52w
FROM stock_prices;
Real-World: Cumulative Distribution
-- What percentage of orders are below each value?
SELECT
order_value,
COUNT(*) OVER (ORDER BY order_value) as orders_at_or_below,
COUNT(*) OVER () as total_orders,
ROUND(100.0 * COUNT(*) OVER (ORDER BY order_value) / COUNT(*) OVER (), 2) as percentile
FROM orders;
| order_value | orders_at_or_below | total_orders | percentile |
|---|---|---|---|
| $25 | 150 | 1000 | 15.00% |
| $50 | 450 | 1000 | 45.00% |
| $100 | 820 | 1000 | 82.00% |
EXCLUDE Clause (PostgreSQL 11+)
Fine-tune what's excluded from the frame:
-- Average excluding the current row
AVG(value) OVER (
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING
EXCLUDE CURRENT ROW
)
Options:
EXCLUDE CURRENT ROW- Exclude current rowEXCLUDE GROUP- Exclude current row and peersEXCLUDE TIES- Exclude peers but keep current rowEXCLUDE NO OTHERS- Default, exclude nothing
Key Takeaways
- ROWS counts physical rows - Exact control, most common for moving averages
- RANGE uses values - Includes ties, good for date ranges with duplicates
- Default frame changes with ORDER BY - Add explicit frame for clarity
- 7-day average = 6 PRECEDING + current - Remember to count correctly!
Next up: Window Functions Assessment - Test your knowledge with practical scenarios.