Window Functions
Window functions perform calculations across a set of rows related to the current row without collapsing them into a single result. Unlike GROUP BY, every input row stays in the output — the function just adds a computed column based on the surrounding "window".
PostgreSQL has full SQL-standard support including ranking, lag/lead, and frame clauses (ROWS / RANGE / GROUPS).
We will use this employees table for examples:
| id | name | dept | salary | hire_date |
|---|---|---|---|---|
| 1 | Alice | IT | 95000 | 2018-03-12 |
| 2 | Bob | IT | 85000 | 2019-06-01 |
| 3 | Carol | IT | 72000 | 2020-09-15 |
| 4 | Dave | Sales | 68000 | 2017-01-10 |
| 5 | Eve | Sales | 92000 | 2018-11-22 |
| 6 | Frank | Sales | 68000 | 2021-04-30 |
| 7 | Gina | Finance | 80000 | 2019-02-18 |
| 8 | Henry | Finance | 78000 | 2020-12-05 |
OVER() — The Window Definition
Every window function is followed by an OVER (...) clause that defines the window:
function_name() OVER (
PARTITION BY col1[, col2, ...] -- groups (optional)
ORDER BY col3[, col4, ...] -- sort within group (optional)
ROWS|RANGE BETWEEN ... AND ... -- frame (optional)
)
PARTITION BY— splits rows into groups, the function resets per group. Without it, the whole result set is one window.ORDER BY— orders rows inside the partition; required by ranking and frame-aware functions.- Frame clause — only applies to aggregates and
FIRST_VALUE/LAST_VALUE/NTH_VALUE.
GROUP BY. With GROUP BY you lose the row detail — with a window function you keep every row and just add the aggregate alongside.
Ranking Functions
ROW_NUMBER() — Unique sequential numbers
Always 1, 2, 3, … even for ties.
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees;
| name | dept | salary | rn |
|---|---|---|---|
| Alice | IT | 95000 | 1 |
| Bob | IT | 85000 | 2 |
| Carol | IT | 72000 | 3 |
| Eve | Sales | 92000 | 1 |
| Dave | Sales | 68000 | 2 |
| Frank | Sales | 68000 | 3 |
| Gina | Finance | 80000 | 1 |
| Henry | Finance | 78000 | 2 |
RANK() and DENSE_RANK() — Handle ties
| Function | Behaviour for ties | Example sequence |
|---|---|---|
RANK() |
Same rank for ties, skips next number | 1, 1, 3, 4 |
DENSE_RANK() |
Same rank for ties, no gap | 1, 1, 2, 3 |
SELECT name, salary,
RANK() OVER (ORDER BY salary DESC) AS rk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM employees;
| name | salary | rk | dr |
|---|---|---|---|
| Alice | 95000 | 1 | 1 |
| Eve | 92000 | 2 | 2 |
| Bob | 85000 | 3 | 3 |
| Gina | 80000 | 4 | 4 |
| Henry | 78000 | 5 | 5 |
| Carol | 72000 | 6 | 6 |
| Dave | 68000 | 7 | 7 |
| Frank | 68000 | 7 | 7 |
NTILE(n) — Bucket rows into n groups
-- Split employees into 4 salary quartiles
SELECT name, salary,
NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees;
LAG and LEAD — Look at neighbouring rows
Returns a value from the row N positions before (LAG) or after (LEAD) the current one. Useful for computing differences over time.
-- Each hire ordered by date, with the hire before them
SELECT name, hire_date,
LAG(name) OVER (ORDER BY hire_date) AS previous_hire,
LEAD(name) OVER (ORDER BY hire_date) AS next_hire
FROM employees;
| name | hire_date | previous_hire | next_hire |
|---|---|---|---|
| Dave | 2017-01-10 | NULL | Alice |
| Alice | 2018-03-12 | Dave | Eve |
| Eve | 2018-11-22 | Alice | Gina |
| Gina | 2019-02-18 | Eve | Bob |
LAG(col, offset, default) lets you set a default instead of NULL:
LAG(salary, 1, 0) OVER (ORDER BY hire_date)
Time-series example — month-over-month change
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS delta,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month), 2
) AS pct_change
FROM monthly_revenue;
FIRST_VALUE, LAST_VALUE, NTH_VALUE
Return a value from a specific position within the window.
SELECT name, dept, salary,
FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
LAST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS bottom_earner
FROM employees;
LAST_VALUE needs an explicit frame. The default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — so without overriding it, LAST_VALUE just returns the current row! Always add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Aggregates as Window Functions
Any aggregate (SUM, AVG, COUNT, MIN, MAX, STRING_AGG, ARRAY_AGG, …) can be turned into a window function by adding OVER.
Running total
SELECT name, hire_date, salary,
SUM(salary) OVER (ORDER BY hire_date) AS running_payroll
FROM employees;
| name | hire_date | salary | running_payroll |
|---|---|---|---|
| Dave | 2017-01-10 | 68000 | 68000 |
| Alice | 2018-03-12 | 95000 | 163000 |
| Eve | 2018-11-22 | 92000 | 255000 |
| Gina | 2019-02-18 | 80000 | 335000 |
Department average alongside individual salary
SELECT name, dept, salary,
ROUND(AVG(salary) OVER (PARTITION BY dept), 0) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY dept) AS diff
FROM employees;
Moving average (last 3 rows)
SELECT month, revenue,
ROUND(AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3m
FROM monthly_revenue;
The Frame Clause
The frame defines which rows inside the partition the function sees. Without it, the default depends on whether ORDER BY is present.
| Default when | Frame |
|---|---|
No ORDER BY |
Whole partition |
ORDER BY present |
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
Frame syntax:
ROWS BETWEEN <start> AND <end> -- physical row offsets
RANGE BETWEEN <start> AND <end> -- value-based (date, number)
GROUPS BETWEEN <start> AND <end> -- peer groups (PG 11+)
Bounds:
UNBOUNDED PRECEDING
N PRECEDING
CURRENT ROW
N FOLLOWING
UNBOUNDED FOLLOWING
Examples
-- Cumulative count
COUNT(*) OVER (ORDER BY hire_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- Sliding 7-day window of revenue (date-aware)
SUM(revenue) OVER (ORDER BY day
RANGE BETWEEN INTERVAL '6 days' PRECEDING
AND CURRENT ROW)
-- Centred moving average (3 rows on either side)
AVG(value) OVER (ORDER BY ts
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING)
Named Windows with WINDOW Clause
If you reuse the same window definition multiple times, name it once with the WINDOW clause:
SELECT name, dept, salary,
RANK() OVER w AS rk,
DENSE_RANK() OVER w AS dr,
AVG(salary) OVER w AS dept_avg
FROM employees
WINDOW w AS (PARTITION BY dept ORDER BY salary DESC);
Cleaner than repeating (PARTITION BY dept ORDER BY salary DESC) three times.
Filtering Window Function Results
Window functions are calculated after WHERE and GROUP BY but before ORDER BY. To filter on a window result, wrap the query in a subquery or CTE.
-- Top 2 earners per department
WITH ranked AS (
SELECT name, dept, salary,
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rk
FROM employees
)
SELECT name, dept, salary
FROM ranked
WHERE rk <= 2;
ROW_NUMBER() for "exactly N", RANK() when you want to include ties.
Practical Patterns
1. Detect gaps in sequences
SELECT id,
id - ROW_NUMBER() OVER (ORDER BY id) AS gap_group
FROM tickets;
Rows with the same gap_group are contiguous; transitions mark a gap.
2. Sessionization — group user events into sessions
SELECT user_id, ts, event,
SUM(CASE WHEN ts - LAG(ts) OVER w > INTERVAL '30 min' THEN 1 ELSE 0 END)
OVER w AS session_id
FROM events
WINDOW w AS (PARTITION BY user_id ORDER BY ts);
3. Percent of total
SELECT product, sales,
ROUND(100.0 * sales / SUM(sales) OVER (), 2) AS pct_of_total
FROM product_sales;
4. Change since first measurement
SELECT day, value,
value - FIRST_VALUE(value) OVER (ORDER BY day) AS change_since_start
FROM metrics;
Performance Notes
- Window functions can be expensive — they typically require a sort unless an index already orders the data the way
PARTITION BY ... ORDER BYneeds. - Reusing a window with
WINDOW w AS ...lets the planner share the sort across multiple functions in one pass. - For "top-N per group" with very wide partitions, a
LATERALjoin withLIMITcan beatROW_NUMBERfiltering.
When to use what
| Need | Use |
|---|---|
| Unique number per row | ROW_NUMBER() |
| Rank with gaps for ties | RANK() |
| Rank without gaps | DENSE_RANK() |
| Bucket into N groups | NTILE(N) |
| Compare to previous/next row | LAG() / LEAD() |
| Running total / cumulative sum | SUM(x) OVER (ORDER BY …) |
| Moving average | AVG(x) OVER (ORDER BY … ROWS BETWEEN n PRECEDING AND CURRENT ROW) |
| First/last in partition | FIRST_VALUE / LAST_VALUE (mind the frame!) |
| Top N per group | ROW_NUMBER() filter in CTE |
| % of total | x / SUM(x) OVER () |
Summary
- Window functions add per-row computed values without collapsing rows.
OVERdefines the window:PARTITION BY(groups),ORDER BY(sort), frame (range).- Ranking:
ROW_NUMBER,RANK,DENSE_RANK,NTILE. - Neighbours:
LAG,LEAD,FIRST_VALUE,LAST_VALUE. - Any aggregate becomes a window function with
OVER. - Filter on window results by wrapping in a subquery or CTE.
- Always set an explicit frame for
LAST_VALUE.