Window Functions — RANK, ROW_NUMBER, LAG/LEAD, Partitions & Frames
A window function computes an aggregate — a sum, a rank, a running total — without collapsing the rows it aggregated over. Where GROUP BY turns many rows into one, a window function stamps the group's answer onto every row and lets each row keep its own identity. That single difference is why SQL can answer "this transaction, and also its merchant's running total" in one pass.
Key Components
- Window function
- A calculation over a set of related rows that leaves every row in the output — one output row per input row, not one per group.
PARTITION BY- Splits rows into groups for the window function to look at, the same way
GROUP BYdoes — but without merging the group into one row. - Window
ORDER BY - The
ORDER BYwritten insideOVER(...). It decides what "before this row" means for ranking,LAG, and running totals. It is separate from the query's own trailingORDER BY, which only decides print order. - Frame clause
- The
ROWS/RANGE/GROUPS BETWEEN ...part ofOVER(...). It picks which slice of the ordered partition a given row's calculation actually sees — the whole partition, a running prefix, or a fixed-size neighbourhood. - Peer group
- Rows the window's
ORDER BYcannot tell apart — they are tied on the ordering key. Ranking functions and the default frame both treat a peer group as a single unit, which is the root of most window-function surprises.
Concrete Example
Before window functions, seeing a row next to a group total meant computing the total separately and joining it back:
-- The old way: aggregate in a subquery, join back to recover the rows
SELECT t.txn_id, t.amount, m.merchant_total
FROM transactions t
JOIN (SELECT merchant_id, SUM(amount) AS merchant_total
FROM transactions GROUP BY merchant_id) m USING (merchant_id);
A window function says the same thing in one pass, one clause, no join:
SELECT txn_id, merchant_id, amount,
SUM(amount) OVER (PARTITION BY merchant_id) AS merchant_total
FROM transactions;
Run against four rows across two merchants, the result keeps every row and adds one column:
| txn_id | merchant_id | amount | merchant_total |
|---|---|---|---|
| 1 | 501 | 40 | 90 |
| 2 | 501 | 50 | 90 |
| 3 | 900 | 20 | 45 |
| 4 | 900 | 25 | 45 |
Four rows in, four rows out. Each row still shows its own txn_id and amount, and merchant_total is the same number for every row that shares a merchant_id — the partition's answer, stamped onto each of its rows.
The single most common production use of this idea is top-N per group. Rank inside a partition with ROW_NUMBER, then keep only the top rows:
WITH ranked AS (
SELECT merchant_id, txn_id, amount,
ROW_NUMBER() OVER (PARTITION BY merchant_id
ORDER BY amount DESC, txn_id) AS rn
FROM transactions
WHERE status = 'SUCCESS' -- filter first: this step runs before ranking
)
SELECT * FROM ranked WHERE rn <= 3;
Filtering status = 'SUCCESS' inside the CTE shrinks the input before ranking runs, and it changes the question being asked: ranks are computed among successful transactions only. Filtering outside would rank everything first and discard afterward — slower, and a different answer.
Visual Model
Think of a window function as a clerk who walks down a sorted stack of rows with a small movable window over part of the stack. At each row, the clerk looks only through the window, computes one number, and writes it on that row before moving on. The two hardest questions are: what happens when two rows in the window tie, and how big is the window. Step through both below — same idea, two different tables.
Both tables show the same rule from two angles: a peer group is one unit, and every function or frame has to decide what "one unit" means. ROW_NUMBER ignores peers entirely; RANK/DENSE_RANK share a rank across them but disagree on whether to leave a gap; and the default RANGE frame shares one running total across them, while ROWS refuses to.
Deeper — Edge Cases & Gotchas
Why window functions can't appear in WHERE
SQL evaluates a query in a fixed order, and window functions slot in late — after WHERE and GROUP BY/HAVING, before DISTINCT and the final ORDER BY:
By the time step 4.5 runs, WHERE (step 3) has already finished. There is no way for WHERE to filter on a value that does not exist yet.
WHERE.
-- ERROR: window functions are not allowed in WHERE
SELECT merchant_id, txn_id,
ROW_NUMBER() OVER (PARTITION BY merchant_id ORDER BY amount DESC) AS rn
FROM transactions
WHERE rn <= 3;
This fails for a structural reason, not a syntax slip. The fix is to wrap the query: a subquery or CTE finishes one query's evaluation entirely, so in the outer query the window result is an ordinary column with no window nature left, and an ordinary WHERE can filter it.
WITH ranked AS (
SELECT merchant_id, txn_id,
ROW_NUMBER() OVER (PARTITION BY merchant_id ORDER BY amount DESC) AS rn
FROM transactions
)
SELECT * FROM ranked WHERE rn <= 3;
One exception: the query's own trailing ORDER BY is allowed to reference a window function, because it runs at step 8 — after window functions have already been computed.
LAST_VALUE returns the current row, not the last row
The default frame is RANGE UNBOUNDED PRECEDING, meaning "from the partition start through the current row's last peer." FIRST_VALUE looks correct only because the frame's start happens to be the partition start. LAST_VALUE is not so lucky — the frame's end is the current row, so "the last row of the frame" is the current row itself.
-- WRONG: returns the CURRENT row's own amount on every row
LAST_VALUE(amount) OVER (PARTITION BY merchant_id ORDER BY created_at)
-- FIX: open the frame at both ends
LAST_VALUE(amount) OVER (PARTITION BY merchant_id ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
-- OR sidestep it entirely
FIRST_VALUE(amount) OVER (PARTITION BY merchant_id ORDER BY created_at DESC)
Deciding ROWS vs RANGE
The rule of thumb that resolves almost every case: "the last 7 rows" is ROWS; "the last 7 days" is RANGE. They differ whenever row density varies — a 7-row average and a 7-day average are the same query only on days with exactly one row. If the ordering column can contain duplicates and the goal is strict row-by-row accumulation, write ROWS explicitly rather than relying on the default.
The idea behind sessionization and gaps-and-islands
GROUP BY can only group on equality of a value — it cannot see a relationship between rows, such as "adjacent" or "no gap since the previous row." The fix is always the same move: compute that relationship into a per-row value that stays constant across the rows that belong together, then group by the constant.
| Problem | The relationship | The constant it becomes |
|---|---|---|
| Sessionize on an idle gap | "no gap > 30 min since the previous row" | running SUM of a 0/1 boundary flag from LAG |
| Runs of consecutive dates | "this date is the previous date + 1" | date - ROW_NUMBER() OVER (ORDER BY date) |
A running SUM over a 0/1 flag is a counter that only increments at boundaries, so every row between two boundaries carries the same total — that total is the session id. For consecutive dates, the date and the row number both increase by exactly 1 per row, so their difference is constant within a run and jumps only at a break.
Cost: a window function needs its partition sorted
EXPLAIN shows a WindowAgg node with a Sort or an Index Scan directly beneath it. An index matching (partition_key, order_key) can supply the rows pre-sorted and remove the Sort entirely — the same leftmost-prefix logic as any composite index. Several functions sharing one named window cost one sort; each distinct window costs its own.
See Also
SQL Fundamentals Establishes the logical evaluation order that window functions slot into at step 4.5, and theGROUP BY grain change that window functions exist to avoid.
Relational Data Model
Tables as sets of tuples — the row-per-tuple grain that a window function is careful to preserve while GROUP BY collapses it.
B-tree Indexes
The sorted structure a window function's partition needs; a matching index can hand it pre-sorted rows and remove the planner's Sort step.
Composite Indexes
An index on (partition_key, order_key) that eliminates a window function's sort is exactly a leftmost-prefix composite index.
Denormalization
Storing a running total or rank as a column is the denormalized alternative to computing it fresh with a window function on every read.
Heap Storage Layout
The window-function deduplication pattern deletes duplicate rows using ctid, the physical tuple identifier this entry explains.
Join Algorithms
The pre-window workaround — aggregate in a subquery, then join it back — pays for exactly the join cost this entry breaks down.
Query Planning & Optimization
The same planner that produces a WindowAgg node fed by a Sort or Index Scan is the subject of this entry.
Sources consulted
- PostgreSQL Documentation — 3.5. Window Functions (tutorial) — cited in source research
- PostgreSQL Documentation — 4.2.8. Window Function Calls (frame clause syntax) — cited in source research
- PostgreSQL Documentation — 9.22. Window Functions (function reference) — cited in source research
- PostgreSQL Documentation — SELECT (logical processing order, WINDOW clause) — cited in source research
Test Yourself
You need exactly the top 3 highest-paid employees per department — never more than 3, even if salaries tie. Which function do you rank with?
SUM(amount) OVER (PARTITION BY merchant_id ORDER BY created_at) is written with no explicit frame clause. Two rows share the same created_at value. What do those two rows see?
You write LAST_VALUE(amount) OVER (PARTITION BY merchant_id ORDER BY created_at) with no explicit frame, expecting the merchant's most recent transaction amount stamped on every row. What actually happens, and why?