Window Functions
A window function computes over a set of rows related to the current row and returns a value for every one of them. sum(total) under a GROUP BY gives one row per customer. The same sum(total) written OVER (PARTITION BY customer_id) gives back every order, each one carrying its customer's total alongside it. Nothing collapses, and nothing has to be joined back on afterwards.
That difference removes more application loops than anything else in this chapter. Cartwheel's "your recent orders" screen currently costs three round trips, one of which exists purely to fetch a customer's entire order history so the service can add up a running total in Python. One OVER clause deletes that query, and the same feature deletes the loop that computes the gap between two delivery events.
OVER, and What It Changes
A window is a set of rows the function is allowed to look at while it computes a value for the current row. PARTITION BY cuts the result into independent groups; ORDER BY inside the OVER clause fixes the order within each group, without which "running", "previous" and "rank" mean nothing. The row count of the query does not change. Window functions are evaluated after WHERE, GROUP BY and HAVING have already run, so they see exactly the rows that survived those clauses and no others.
SELECT customer_id, count(*), sum(total) FROM orders WHERE placed_at >= '2026-08-01' GROUP BY customer_id; -- one row per customer SELECT id, customer_id, placed_at, total, sum(total) OVER (PARTITION BY customer_id ORDER BY placed_at) AS running_spend FROM orders WHERE placed_at >= '2026-08-01'; -- every order, plus the total
The first query answers "how much has each customer spent" and throws the orders away. Written as a self-join, the second would read orders twice and correlate the table with itself; written as a window it makes one pass. What it answers is "how much had this customer spent as of this order", and it keeps the rows — a list of orders with a number beside each one, which is the shape the screen actually needs.
The Three Families
Ranking functions number the rows: row_number() assigns 1, 2, 3 and never ties, rank() gives tied rows the same number and then skips (1, 1, 3), dense_rank() gives tied rows the same number and does not skip (1, 1, 2), and ntile(n) splits the partition into n roughly equal buckets. Offset functions read a different row of the same partition: lag() and lead() step backwards and forwards, first_value(), last_value() and nth_value() pick a row out of the frame. And any ordinary aggregate — sum, count, avg, max — becomes a window function the moment you attach OVER to it.
SELECT order_id, event_type, occurred_at,
occurred_at - lag(occurred_at) OVER (PARTITION BY order_id
ORDER BY occurred_at) AS since_prev
FROM delivery_events
WHERE occurred_at >= date_trunc('day', now());
lag() returns the value of occurred_at from the previous row of the same order, so subtracting it produces the interval between one event and the one before it, on the row where it belongs. The first event of each order gets NULL, because there is no previous row — and lag() takes an optional default argument for exactly that case. The database does it inside the scan it was already doing, where Cartwheel currently pulls a day of delivery_events — about 4 million rows — into the analytics job and diffs them in Python.
Frames, and the Default That Surprises People
Inside a partition, the function does not necessarily see the whole partition. It sees a frame, and the default frame is where correct-looking running totals go wrong. When the window has an ORDER BY, the default is RANGE UNBOUNDED PRECEDING, which is the same as RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: the frame runs from the start of the partition through the current row's last peer, where a peer is any row the window's ORDER BY sorts as equivalent. Without an ORDER BY every row is a peer of every other, so the frame is the entire partition — which is why sum(total) OVER (PARTITION BY customer_id) gives the same grand total on every row.
sum(total) OVER (PARTITION BY customer_id ORDER BY placed_at) -- RANGE (the default): every order sharing this exact placed_at -- is already counted, on all of those rows sum(total) OVER (PARTITION BY customer_id ORDER BY placed_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- ROWS: strictly this row and the rows physically before it
In RANGE mode the running total jumps by the whole tie group at once and then repeats that value across every tied row. In ROWS mode it advances one row at a time, and that is almost certainly what the query was written to do. The bug never shows up in a test fixture with distinct timestamps and always shows up in production, and it becomes certain rather than likely the moment the ordering key is coarse — order by date_trunc('day', placed_at) and every order placed that day is a peer of every other. A third mode, GROUPS, counts offsets in peer groups rather than rows, and a frame_exclusion clause (EXCLUDE CURRENT ROW, EXCLUDE GROUP, EXCLUDE TIES) removes the current row or its peers from a frame that would otherwise include them.
The same default explains a second piece of folklore. last_value() over an ordered window returns the current row, every time, and people conclude the function is broken. It is not: the frame ends at the current row's last peer, so the last value in the frame is the current row. The fix is to say what you meant — ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — or to use first_value() over the reversed ordering instead.
FILTER and Named Windows
Aggregates used as window functions accept a FILTER clause, which feeds only the matching rows to the function and discards the rest. It expresses "count the cancelled ones" without the CASE WHEN … THEN 1 END contortion, and it is restricted to aggregates — ranking and offset functions do not take it. A WINDOW clause names a definition so several functions can share it, which is a performance decision as much as a stylistic one.
SELECT c.city, o.id, o.total,
rank() OVER w AS rank_in_city,
sum(o.total) OVER w AS city_running_total,
count(*) FILTER (WHERE o.status = 'cancelled')
OVER w AS cancelled_so_far
FROM orders o
JOIN customers c ON c.id = o.customer_id
WINDOW w AS (PARTITION BY c.city ORDER BY o.placed_at);
All three functions reference the same named window, so Postgres computes them together over one ordering of the data. OVER w and OVER (w …) are not the same thing: the bare form uses the named definition, while the parenthesized form copies it and modifies it. Postgres rejects the second outright if the referenced definition already contains a frame clause.
What a Window Costs
A window function is executed by a WindowAgg node, and that node requires its input already sorted by the partition keys followed by the ordering keys. The planner has two ways to supply that: sort the rows, or read them in order from an index whose columns match. On orders, a B-tree on (customer_id, placed_at) turns the sort into an ordered scan and the Sort node disappears from the plan entirely — the same trick Chapter 8 uses for ORDER BY … LIMIT, applied to a different consumer.
Several functions sharing one window definition are evaluated in a single WindowAgg over a single ordering. Several different definitions each get their own node, and each node that needs a different order costs another sort of the intermediate result. Over 40 million orders that is the difference between a query that runs on the primary and one that runs on pg-replica-a at night. Five windows that differ only in whether they say ORDER BY placed_at or ORDER BY placed_at DESC are five sorts of the same rows, and Chapter 9 makes reading that off a plan routine.
Where Windows Beat the Alternatives
The recurring shapes are top-N per group, deduplication, gaps and islands, and period-over-period change. Each one traditionally costs either a self-join with a correlated subquery or a loop in the application, and each becomes one pass over sorted input. There is one structural restriction to plan around: window functions are permitted only in the select list and the ORDER BY clause, because WHERE is evaluated before the windows are computed. A rank has to be produced in a subquery or a CTE and filtered from outside it.
SELECT id, order_id, event_type, occurred_at
FROM (SELECT de.*,
row_number() OVER (PARTITION BY order_id, event_type
ORDER BY occurred_at DESC) AS rn
FROM delivery_events de
WHERE occurred_at >= '2026-08-12') ranked
WHERE rn = 1;
The inner query numbers each order's events of a given type, newest first; the outer query keeps the number-one row of each group. Writing WHERE row_number() OVER (…) = 1 directly is rejected, because WHERE runs before the windows are computed. The subquery is that evaluation order made visible. For this particular shape, one row per group, a later topic in this chapter has a shorter and usually faster Postgres-specific form; the window version earns its place when the rank itself is part of the answer, or when you need the second and third rows too.
GROUP BY — collapses each group into one row and discards the detail. Right when the summary is the whole answer, and cheaper than a window because there is less output to carry.
A window function — keeps every row and attaches the computation to it. Right when the answer must sit next to the detail rows, or when the per-row position within its group is the thing being asked for.
DISTINCT ON — the Postgres shortcut for "one row per group, the first by this ordering". Shorter than row_number() = 1 and usually faster, and it gets a topic of its own later in this chapter.
- Assuming the default frame advances one row at a time — it is
RANGE, so the running total jumps by the whole tie group and repeats across every tied row, and only real data has ties. - Filtering on a window function in
WHERE— window functions are allowed only in the select list andORDER BY, so the rank must be computed in a subquery or CTE and filtered outside it. - Using
last_value()with the default frame and reporting the bug — the frame ends at the current row's last peer, so the answer is the current row until the frame is stated explicitly. - Writing five windows with five nearly identical definitions — each distinct definition is its own
WindowAgg, and each different ordering costs another sort of 40 million rows. - Reaching for a window where a plain
GROUP BYwas wanted and then addingDISTINCTon top — the duplication was created by the query and is being paid for twice. - Ordering a window by a truncated timestamp such as
date_trunc('day', placed_at)without switching toROWS— every order that day becomes a peer, so the running total is a daily total repeated.
- State the frame explicitly with
ROWS BETWEENon any window that has anORDER BYand computes a running value, rather than inheritingRANGEby accident. - Define the window once in a
WINDOWclause and reference it by name from every function that shares it, so the ordering is computed once. - Build a B-tree index matching the
PARTITION BYthenORDER BYcolumn order when a window query runs often, and confirm theSortnode has left the plan. - Compute ranks in a subquery or CTE and filter on them in the enclosing query, because
WHEREruns before any window is evaluated. - Use
FILTERfor conditional aggregates over a window instead ofCASEexpressions, and keep it to aggregates, which are the only functions that accept it. - Pick
row_number(),rank()ordense_rank()deliberately by deciding what ties should do, since the three differ only when two rows are equal and that is exactly when it matters.
Knowledge Check
What does replacing GROUP BY customer_id with OVER (PARTITION BY customer_id) do to the result?
- It returns one row per customer, with the partition acting as the group
- It returns every input row, each carrying its customer's aggregate value
- It returns every row in the table, ignoring the query's own WHERE clause
- It returns one row per distinct value of the window's ORDER BY column
A running total ordered by placed_at is correct until two orders share a timestamp, then it jumps. Why?
- The sort order between the two tied rows is arbitrary, so the sum varies
- A window ORDER BY requires unique values, and ties silently degrade it
- The default RANGE frame includes every row tied at the current value
- The sum() aggregate counts duplicate values twice inside a single frame
Why must WHERE row_number() OVER (...) = 1 be rewritten as a subquery with the filter outside?
- Because no index can support a predicate on a computed ranking column
- Because WHERE is evaluated before window functions are computed at all
- Because Postgres rewrites the predicate into a slower correlated subquery
- Because the predicate belongs in HAVING, which runs after the windows do
A report defines five windows that differ only in their ORDER BY direction. What does that cost in the plan?
- Nothing, since the planner merges windows over the same partition key
- A WindowAgg per definition, and a re-sort wherever the order differs
- One extra scan of the base table for every row the report returns
- One temporary table per window, written to disk and then joined back
Which change makes last_value() return the partition's final row rather than the current one?
- Reversing the window's ORDER BY so the final row is sorted first
- Framing it ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
- Dropping the window's ORDER BY so that no ordering is applied at all
- Adding EXCLUDE CURRENT ROW so the frame skips the row being computed
You got correct