Window Functions
7 pages · ~5 min✓ Reviewed
Builds on Subqueries and CTEs. Next up: Indexes and Query Plans.
Window Functions Mental Model
1. What a Window Function Is
- Computes a value per row over a set of related rows
- Rows are not collapsed; GROUP BY returns one row per group
OVER()turns an aggregate or ranking function into a window function- Empty
OVER()= window is the whole result set
2. PARTITION BY and ORDER BY
| Inside OVER | Effect |
|---|---|
PARTITION BY | Independent groups; function restarts in each |
ORDER BY | Row order in the partition |
ORDER BY + aggregate | Default frame becomes running |
SELECT cust, d, amount, SUM(amount) OVER ( PARTITION BY cust ORDER BY d ) AS running FROM orders;
running total restarts per customer
3. Where It Runs
- WHERE
- GROUP BY
- HAVING
- Window
- ORDER BY, LIMIT
Windows run after them, so filter a window result in an outer query or CTE.
Ranking: ROW_NUMBER, RANK, DENSE_RANK
1. Three Ranking Functions
| Function | Scores 90, 80, 80, 70 | Ties |
|---|---|---|
ROW_NUMBER() | 1, 2, 3, 4 | unique, never tied |
RANK() | 1, 2, 2, 4 | same number, gap after |
DENSE_RANK() | 1, 2, 2, 3 | same number, no gap |
ROW_NUMBERbreaks ties in arbitrary order- Order becomes stable only with a tiebreaker column
2. Rank Within a Group
- PARTITION BY restarts numbering in each department
- ORDER BY salary DESC gives the top pay number 1
SELECT name, dept, salary, ROW_NUMBER() OVER ( PARTITION BY dept ORDER BY salary DESC, id ) AS rn FROM employees;
employees numbered by pay inside each dept
3. Gotchas
- Ranking with no
ORDER BYinOVERis meaningless or an error - Tied rows get arbitrary
ROW_NUMBERs, so results can change between runs
End the OVER ordering with a unique column such as id so ROW_NUMBER is deterministic.
LAG and LEAD
1. Looking at Other Rows
LAG(col, n, default)reads n rows before the current rowLEAD(col, n, default)reads n rows after- Defaults: n = 1, default = NULL
- First row of a partition: LAG gives NULL unless a default is set
2. Month-over-Month Change
SELECT month, amount, amount - LAG(amount) OVER (ORDER BY month) AS change FROM sales;
| month | amount | change |
|---|---|---|
| Jan | 100 | NULL |
| Feb | 120 | 20 |
| Mar | 90 | -30 |
current row minus previous row
- Add PARTITION BY so rows never read across groups
- One sequence per customer or per product
LAG(amount, 1, 0) OVER ( PARTITION BY customer_id ORDER BY month)
default 0 replaces the edge NULL
3. Gotchas
- No ORDER BY in OVER: previous row is meaningless
- Edge-row NULL breaks arithmetic:
amount - NULLis NULL - Fix with the default argument or
COALESCE(LAG(...), 0)
LAG looks back, LEAD looks ahead. Always set ORDER BY, and handle the edge-row NULL.
Running Totals and Frames
1. Frame Clause
ROWS | RANGE | GROUPS BETWEEN start AND end- Bounds: UNBOUNDED PRECEDING,
n PRECEDING, CURRENT ROW,n FOLLOWING, UNBOUNDED FOLLOWING
- With ORDER BY: default is
RANGE UNBOUNDED PRECEDINGtoCURRENT ROW - No ORDER BY: default is the whole partition
2. ROWS vs RANGE
| ROWS | RANGE | |
|---|---|---|
| Counts | physical rows | value peers |
| Tied dates | different totals | same total |
3. Patterns
SUM(x) OVER (PARTITION BY g ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- running total AVG(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) -- last 7 rows
Default frame ends at the current row's peer, not the partition end. Add UNBOUNDED FOLLOWING.
Top-N per Group and Common Mistakes
1. The Top-N Pattern
- Rank in a CTE or subquery, then filter
WHERE rn <= Nin the outer query - Window functions can't be filtered in the same SELECT's WHERE, so wrap them first
WITH r AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY g ORDER BY v DESC) rn FROM t) SELECT * FROM r WHERE rn <= 3;
top 3 per group
2. Ties and Latest Row
| Function | Rows per group |
|---|---|
ROW_NUMBER | exactly N |
RANK / DENSE_RANK | keeps ties, may exceed N |
- Latest row per group:
ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) - Then keep rn = 1
3. Common Mistakes
ORDER BYinsideOVERdoes not sort the output- Add a final
ORDER BYif you need ordered results
The rank column exists only once the CTE has run, so WHERE rn <= N always goes in the outer query.
Window Functions Cheat Sheet
1. Syntax and Ranking
func() OVER (PARTITION BY cols ORDER BY cols frame)- Ranking needs ORDER BY inside OVER
| Function | On ties | Output |
|---|---|---|
ROW_NUMBER | unique | 1,2,3,4 |
RANK | gaps | 1,2,2,4 |
DENSE_RANK | no gaps | 1,2,2,3 |
2. Offsets and Running Total
LAG(col,n,default)looks backLEAD(col,n,default)looks ahead- Edge rows give NULL unless a default is set
SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
running total
3. Top-N per Group
ROW_NUMBERin a CTE, then filterrn <= N
WITH r AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY g ORDER BY v DESC) rn FROM t) SELECT * FROM r WHERE rn <= 3;
On ties ROW_NUMBER stays unique, RANK skips, DENSE_RANK doesn't. ROWS counts rows; RANGE includes peers.
Check yourself
Salaries 90, 80, 80, 70 sorted DESC. What do ROW_NUMBER, RANK and DENSE_RANK return?
ROW_NUMBER: 1, 2, 3, 4 (tie order arbitrary)RANK: 1, 2, 2, 4 (gap after the tie)DENSE_RANK: 1, 2, 2, 3 (no gap)
Why does WHERE rn <= 3 fail when rn is a ROW_NUMBER in the same SELECT?
WHEREruns before window functions, sorndoes not exist yet- Compute
rnin a CTE, then filter in the outer query
Amounts by month are 100, 130, 120. What does amount - LAG(amount) OVER (ORDER BY month) return?
- NULL, 30, -10
- The first row has no previous row, so
LAGis NULL - Pass a default or use
COALESCEto avoid the NULL
Dates 1, 2, 2, 3 with amounts 10, 20, 30, 40. What does SUM(amount) OVER (ORDER BY d) return?
- 10, 60, 60, 100
- Default frame is
RANGE: tied dates are peers and share one total - With
ROWSit would be 10, 30, 60, 100