Window Functions
Perform calculations across related rows without collapsing them with OVER, PARTITION BY, and window frames
Window functions perform calculations across a set of rows related to the current row—without grouping them into a single output row. They're essential for rankings, running totals, moving averages, and comparisons.
How Window Functions Work
Window Function Syntax
function_name(expression) OVER (
[PARTITION BY partition_expression]
[ORDER BY sort_expression [ASC|DESC]]
[frame_clause]
)-- OVER() without options: entire result set is the window
SELECT
name,
price,
AVG(price) OVER() AS avg_all_products,
price - AVG(price) OVER() AS diff_from_avg
FROM products;| name | price | avg_all_products | diff_from_avg |
|---|---|---|---|
| Widget | 50 | 100 | -50 |
| Gadget | 150 | 100 | 50 |
| Thing | 100 | 100 | 0 |
-- PARTITION BY: Create separate windows for each group
SELECT
category_id,
name,
price,
AVG(price) OVER(PARTITION BY category_id) AS category_avg,
price - AVG(price) OVER(PARTITION BY category_id) AS diff_from_category_avg
FROM products;-- ORDER BY: Define order within the window for running calculations
SELECT
created_at,
total,
SUM(total) OVER(ORDER BY created_at) AS running_total,
ROW_NUMBER() OVER(ORDER BY created_at) AS row_num
FROM orders;| created_at | total | running_total | row_num |
|---|---|---|---|
| 2024-01-01 | 100 | 100 | 1 |
| 2024-01-02 | 150 | 250 | 2 |
| 2024-01-03 | 200 | 450 | 3 |
-- Frame: Define exactly which rows to include
SELECT
date,
revenue,
-- 3-day moving average (current row + 2 preceding)
AVG(revenue) OVER(
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3d,
-- Sum of current row and next row
SUM(revenue) OVER(
ORDER BY date
ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING
) AS sum_with_next
FROM daily_sales;Window Frame Types
-- ROWS: Physical rows
SELECT
date,
revenue,
-- Previous 2 physical rows + current
SUM(revenue) OVER(
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS sum_3_rows
FROM daily_sales;Frame boundaries for ROWS:
UNBOUNDED PRECEDING: Start of partitionn PRECEDING: n rows before currentCURRENT ROW: The current rown FOLLOWING: n rows after currentUNBOUNDED FOLLOWING: End of partition
-- RANGE: Logical values (ties included)
SELECT
sale_date,
amount,
-- All rows with same date + preceding dates
SUM(amount) OVER(
ORDER BY sale_date
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM sales;ROWS vs RANGE
With ROWS, each physical row is separate. With RANGE, rows with the same ORDER BY value are treated together.
-- GROUPS: Groups of tied rows (PostgreSQL 11+)
SELECT
category,
product,
price,
-- Average including 1 group before and 1 after
AVG(price) OVER(
ORDER BY category
GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING
) AS avg_nearby_categories
FROM products;| Frame Type | Unit | Ties |
|---|---|---|
ROWS | Physical rows | Separate |
RANGE | Value range | Included together |
GROUPS | Groups of ties | Grouped |
-- Example with duplicate dates
-- Data: 2024-01-01 (x2), 2024-01-02 (x1)
-- ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
-- Row 1: Only itself
-- Row 2 (same date): Rows 1-2
-- Row 3: Rows 2-3
-- RANGE BETWEEN 1 PRECEDING AND CURRENT ROW
-- Row 1: Rows 1-2 (same date = same range)
-- Row 2: Rows 1-2
-- Row 3: Rows 1-3 (value-based range)Ranking Functions
-- Unique number for each row
SELECT
name,
score,
ROW_NUMBER() OVER(ORDER BY score DESC) AS row_num
FROM students;| name | score | row_num |
|---|---|---|
| Alice | 95 | 1 |
| Bob | 90 | 2 |
| Charlie | 90 | 3 |
| Diana | 85 | 4 |
Non-Deterministic
With ties, ROW_NUMBER is non-deterministic unless you add a tiebreaker.
-- Rank with gaps after ties
SELECT
name,
score,
RANK() OVER(ORDER BY score DESC) AS rank
FROM students;| name | score | rank |
|---|---|---|
| Alice | 95 | 1 |
| Bob | 90 | 2 |
| Charlie | 90 | 2 |
| Diana | 85 | 4 |
Note: Rank 3 is skipped because 2 people tied for 2nd.
-- Rank without gaps
SELECT
name,
score,
DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rank
FROM students;| name | score | dense_rank |
|---|---|---|
| Alice | 95 | 1 |
| Bob | 90 | 2 |
| Charlie | 90 | 2 |
| Diana | 85 | 3 |
Note: No gaps—Diana is 3rd, not 4th.
-- Divide into n buckets
SELECT
name,
score,
NTILE(4) OVER(ORDER BY score DESC) AS quartile
FROM students;| name | score | quartile |
|---|---|---|
| Alice | 95 | 1 |
| Bob | 90 | 2 |
| Charlie | 85 | 3 |
| Diana | 80 | 4 |
Use cases: Percentiles, quartiles, deciles
| Function | Ties | Gaps | Use Case |
|---|---|---|---|
ROW_NUMBER | Arbitrary | No | Unique IDs, pagination |
RANK | Same rank | Yes | Competition rankings |
DENSE_RANK | Same rank | No | Sequential rankings |
NTILE(n) | N/A | N/A | Buckets/percentiles |
-- All together
SELECT
name,
score,
ROW_NUMBER() OVER(ORDER BY score DESC) AS row_num,
RANK() OVER(ORDER BY score DESC) AS rank,
DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rank,
NTILE(4) OVER(ORDER BY score DESC) AS quartile
FROM students;Navigation Functions
-- Access previous row's value
SELECT
date,
revenue,
LAG(revenue) OVER(ORDER BY date) AS prev_day_revenue,
LAG(revenue, 7) OVER(ORDER BY date) AS week_ago_revenue,
LAG(revenue, 1, 0) OVER(ORDER BY date) AS prev_or_zero -- Default if no prev
FROM daily_sales;| date | revenue | prev_day | week_ago |
|---|---|---|---|
| Day 1 | 100 | NULL | NULL |
| Day 2 | 150 | 100 | NULL |
| Day 3 | 120 | 150 | NULL |
-- Access next row's value
SELECT
date,
revenue,
LEAD(revenue) OVER(ORDER BY date) AS next_day_revenue,
LEAD(revenue, 7) OVER(ORDER BY date) AS next_week_revenue
FROM daily_sales;
-- Calculate day-over-day change
SELECT
date,
revenue,
revenue - LAG(revenue) OVER(ORDER BY date) AS dod_change,
ROUND(
(revenue - LAG(revenue) OVER(ORDER BY date)) * 100.0
/ LAG(revenue) OVER(ORDER BY date),
2
) AS dod_pct_change
FROM daily_sales;-- First value in the window
SELECT
category_id,
name,
price,
FIRST_VALUE(name) OVER(
PARTITION BY category_id
ORDER BY price DESC
) AS most_expensive_in_category,
FIRST_VALUE(price) OVER(
PARTITION BY category_id
ORDER BY price DESC
) AS max_price_in_category
FROM products;-- Last value in the window
-- ⚠️ Requires proper frame definition!
SELECT
category_id,
name,
price,
LAST_VALUE(name) OVER(
PARTITION BY category_id
ORDER BY price DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS cheapest_in_category
FROM products;LAST_VALUE Gotcha
By default, the window frame ends at CURRENT ROW, not the partition end. Always specify the full frame for LAST_VALUE!
-- Get the Nth value in the window
SELECT
category_id,
name,
price,
NTH_VALUE(name, 2) OVER(
PARTITION BY category_id
ORDER BY price DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS second_most_expensive
FROM products;Aggregate Window Functions
-- Any aggregate function can be used as a window function
SELECT
date,
category_id,
revenue,
-- Aggregates over entire partition
SUM(revenue) OVER(PARTITION BY category_id) AS category_total,
AVG(revenue) OVER(PARTITION BY category_id) AS category_avg,
COUNT(*) OVER(PARTITION BY category_id) AS category_count,
-- Running aggregates
SUM(revenue) OVER(
PARTITION BY category_id
ORDER BY date
) AS running_total,
AVG(revenue) OVER(
PARTITION BY category_id
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM sales;Common Patterns
Named Window Definitions
-- Define once, use multiple times
SELECT
date,
category_id,
revenue,
SUM(revenue) OVER category_window AS category_total,
AVG(revenue) OVER category_window AS category_avg,
RANK() OVER category_window AS category_rank
FROM sales
WINDOW category_window AS (
PARTITION BY category_id
ORDER BY revenue DESC
);
-- Multiple named windows
SELECT
*,
SUM(amount) OVER running AS running_total,
AVG(amount) OVER moving AS moving_avg
FROM transactions
WINDOW
running AS (ORDER BY date),
moving AS (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW);Performance Considerations
Add Indexes for ORDER BY Columns
Window functions benefit from indexes on the columns used in ORDER BY.
CREATE INDEX idx_orders_date ON orders(created_at);
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at);Minimize Window Function Calls
Combine multiple window calculations using the same window.
-- ❌ Multiple window scans
SELECT
SUM(x) OVER(ORDER BY date),
AVG(x) OVER(ORDER BY date),
COUNT(x) OVER(ORDER BY date)
FROM t;
-- ✅ Named window (potentially one scan)
SELECT
SUM(x) OVER w,
AVG(x) OVER w,
COUNT(x) OVER w
FROM t
WINDOW w AS (ORDER BY date);Be Careful with ROWS vs RANGE
RANGE can be slower because it must handle ties.
Filter After Window Calculation
Window functions execute after WHERE but before final SELECT. Use CTEs or subqueries to filter on window results.
-- ✅ Filter window results in outer query
WITH ranked AS (
SELECT *, RANK() OVER(ORDER BY score DESC) AS r
FROM scores
)
SELECT * FROM ranked WHERE r <= 10;