Window Functions
Definition
Window functions perform a calculation across a set of related rows (a “window”) without collapsing them into a single output row, unlike
GROUP BY. Every input row keeps its own row in the result, but gains access to aggregate/ranking values computed across its window.
graph LR A["GROUP BY N rows in -> fewer rows out (rows collapsed)"] B["Window Function (OVER) N rows in -> N rows out (each row keeps its identity, gains a computed value)"]
Basic Syntax
function_name(...) OVER (
[PARTITION BY column]
[ORDER BY column]
[frame_clause]
)SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary
FROM employees;Every employee row is preserved, but each also shows their department’s average alongside their own salary - impossible with plain
GROUP BY, which would collapse to one row per department.
Ranking Functions
SELECT
name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rank
FROM employees;| Function | Behavior on ties |
|---|---|
ROW_NUMBER() | assigns a unique, sequential number regardless of ties (1,2,3,4…) |
RANK() | ties share the same rank, next rank skips accordingly (1,2,2,4…) |
DENSE_RANK() | ties share the same rank, next rank does NOT skip (1,2,2,3…) |
-- Classic pattern: top-N per group (e.g. top 3 highest-paid employees per department)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
FROM employees
) ranked
WHERE rn <= 3;NTILE - Bucket Rows into N Groups
SELECT name, salary, NTILE(4) OVER (ORDER BY salary) AS salary_quartile
FROM employees;Splits rows into 4 roughly equal-sized buckets ordered by salary - quartile 1 is the lowest-paid, quartile 4 the highest.
Aggregate Functions as Window Functions
SELECT
name, department, salary,
SUM(salary) OVER (PARTITION BY department) AS dept_total,
COUNT(*) OVER (PARTITION BY department) AS dept_headcount,
salary / SUM(salary) OVER (PARTITION BY department) AS pct_of_dept_total
FROM employees;Any aggregate function works with
OVER
SUM,AVG,COUNT,MIN,MAXall work as window functions simply by addingOVER (...)- the key difference fromGROUP BYis that individual rows are preserved.
Running Totals & Moving Calculations
SELECT
order_date, amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total,
AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS trailing_7day_avg
FROM orders;Frame Clauses - Controlling Which Rows Are “In the Window”
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- from the start through the current row (running total)
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING -- a centered window of 5 rows
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING -- from current row to the end
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW -- time-based window, needs ORDER BY on a date/time column
ROWSvsRANGE
ROWScounts a fixed number of physical rows.RANGE(or the newerGROUPS) considers logical value ranges - e.g.RANGE INTERVAL '7 days'includes all rows within 7 days of the current row’s value, regardless of exact row count.RANGEis essential for genuinely time-based rolling windows.
LAG & LEAD - Access Other Rows’ Values
SELECT
order_date, amount,
LAG(amount) OVER (ORDER BY order_date) AS previous_amount,
LEAD(amount) OVER (ORDER BY order_date) AS next_amount,
amount - LAG(amount) OVER (ORDER BY order_date) AS change_from_previous
FROM orders;
LAG(amount, 2) OVER (ORDER BY order_date) -- look back 2 rows instead of 1
LAG(amount, 1, 0) OVER (ORDER BY order_date) -- default to 0 instead of NULL for the first row
LAG/LEADfor period-over-period comparisonsThe standard way to compute “change from previous day/month/order” without a self-join - much simpler and faster than joining the table to itself on a shifted date condition.
FIRST_VALUE & LAST_VALUE
SELECT
name, department, salary,
FIRST_VALUE(name) OVER (PARTITION BY department ORDER BY salary DESC) AS highest_paid_in_dept
FROM employees;
LAST_VALUEneeds an explicit frame to behave as expectedBy default, the window frame only extends to the “current row,” so
LAST_VALUEoften just returns the current row’s own value. Fix with an explicit frame:LAST_VALUE(name) OVER (PARTITION BY department ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).
Named Windows (WINDOW Clause - Avoid Repetition)
SELECT
name,
RANK() OVER w AS rank,
SUM(salary) OVER w AS running_total
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);Define the window specification once, reuse it across multiple function calls in the same query - keeps things DRY when several window functions share the same
PARTITION BY/ORDER BY.