Window Functions
Advanced⏱️ 25 mins read
What You'll Learn
Window functions perform calculations across rows related to the current row WITHOUT collapsing them like GROUP BY. They use the OVER() clause with optional PARTITION BY (grouping) and ORDER BY (ordering within partition). Key functions: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM, AVG, FIRST_VALUE, LAST_VALUE.
Syntax
FUNCTION() OVER (
PARTITION BY col
ORDER BY col
ROWS BETWEEN ... AND ...
)Example
-- ROW_NUMBER, RANK, DENSE_RANK per department
SELECT name, salary, department_id,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS drnk
FROM employees;
-- LAG: compare each order to the previous one, by date
SELECT
order_date,
total,
LAG(total) OVER (ORDER BY order_date) AS prev_total,
total - LAG(total) OVER (ORDER BY order_date) AS change_from_prev
FROM orders;
-- Running total
SELECT order_date, total,
SUM(total) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;Beyond the Basics
Data in play — reference tables for this topic
-- employees (from Topic 1)
-- id | name | department_id | salary
-- 1 | Ada | 1 | 145000
-- 2 | Grace | 1 | 115000
-- 3 | Alan | 2 | 92000
-- 4 | Edsger | NULL | 78000
-- 5 | Barbara | 3 | 64000
-- orders (from Topic 7)
-- id | total | order_date
-- 1 | 240.00 | 2024-01-15
-- 2 | 89.90 | 2024-02-03
-- 3 | 430.00 | 2024-03-11
-- 4 | 59.50 | 2024-04-02
-- 5 | 120.00 | 2024-05-20Windows vs GROUP BY: rows stay, answers get added
GROUP BY department_id gives you 4 rows (Topic 9). The same PARTITION BY gives every employee their group's stats while keeping all 5 rows — Edsger included, in his own NULL partition, exactly like GROUP BY's NULL group. GROUP BY answers 'per group'; windows answer 'per group, per row'.
SELECT name, salary,
COUNT(*) OVER (PARTITION BY department_id) AS dept_headcount,
MAX(salary) OVER (PARTITION BY department_id) AS dept_top_salary
FROM employees;
-- Ada | 145000 | 2 | 145000
-- Grace | 115000 | 2 | 145000
-- Alan | 92000 | 1 | 92000
-- Edsger | 78000 | 1 | 78000 <- his own NULL partition
-- Barbara | 64000 | 1 | 64000Running totals and LAG without self-joins
Before windows, a running total meant a self-join or a correlated subquery (Topic 16's per-row cost). The frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW does it in one ordered pass. Note the first order's LAG is NULL — date comparisons on that row must expect it (Topic 7 again).
SELECT order_date, total,
SUM(total) OVER (ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM orders;
-- 240.00 → 329.90 → 759.90 → 819.40 → 939.40
SELECT order_date,
total - LAG(total) OVER (ORDER BY order_date) AS delta
FROM orders;
-- First row: LAG is NULL → delta is NULL, not 0Windows can't live in WHERE — wrap them
WHERE runs before SELECT (Topic 2), so window results don't exist yet when it filters. The fix is always the same: compute the window in a CTE (Topic 17), filter outside. This is exactly how Topic 16's slow correlated 'top earner per department' becomes one clean pass.
WITH ranked AS (
SELECT name, salary, department_id,
ROW_NUMBER() OVER (
PARTITION BY department_id ORDER BY salary DESC) AS rn
FROM employees
)
SELECT name, salary, department_id
FROM ranked WHERE rn = 1;
-- Ada (1), Alan (2), Barbara (3) — and no NULL-group surprise:
-- Edsger is rank 1 of his own partition, so he appears tooCommon Mistakes
Confusing RANK and DENSE_RANK: RANK gives 1,2,2,4 (skips 3). DENSE_RANK gives 1,2,2,3 (no skip). ROW_NUMBER always gives unique sequential numbers regardless of ties. Window functions cannot be used in WHERE — wrap in a CTE or subquery.
Interview Tips
Window functions are the #1 topic at FAANG-level SQL interviews. Know ROW_NUMBER for deduplication (keep latest row), RANK/DENSE_RANK for top-N per group, LAG/LEAD for period-over-period comparisons, and running totals.
Official References
Test Yourself — 6 questions
1. The core difference between window functions and GROUP BY?
2. COUNT(*) OVER (PARTITION BY department_id) on ShopCo shows Ada…?
3. Which partition does Edsger (department_id NULL) land in?
4. The running-total window needs which frame?
5. LAG(total) on the FIRST order (by order_date) returns…?
6. Why can't you write WHERE rn = 1 directly on ROW_NUMBER()?
Practice
Return each employee's name, department_id, and salary, plus their salary rank within their department (1 = highest).
⚡ Solve it in the SQL playground →Frequently Asked Questions
When do I still need GROUP BY instead of a window?
When the output should be one row per group — counts, totals, averages for a report. Windows keep detail rows; GROUP BY collapses them. If you catch yourself GROUP BY-ing and then re-joining detail rows, you wanted a window.
Do window functions hurt performance?
They need their input sorted by PARTITION BY + ORDER BY, which is real cost on large tables — but it is one sort versus a GROUP BY plus a self-join, which is usually worse. An index matching the window's ordering can eliminate the sort (Topic 21). EXPLAIN and compare (Topic 22).