Topic 17

Window Functions

Analytic SQL

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.

The same sum(total), two entirely different result sets
GROUP BY customer_idone row per customer
Collapses. The orders that went into each total are gone from the result, and anything that still needs them has to be joined back on afterwards.
OVER (PARTITION BY customer_id)every order, plus the total
Keeps every row. Each order carries its customer's total alongside it, which is the shape the screen actually needs — a list of orders with a number beside each one.

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.

The same aggregate, two entirely different results
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.

The gap between two delivery events, without a second query
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.

Two running totals that disagree the moment two rows tie
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.

Say what the frame is, and the surprises stop
Everything from the start of the partition through the current row's last peerRANGE — the default
One row at a time, however many rows tie on the ordering keyROWS BETWEEN
Offsets counted in peer groups rather than in rowsGROUPS
The whole partition in both directionsUNBOUNDED FOLLOWING

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.

One window definition, three functions over it
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.

Deduplicating a replayed feed: rank inside, filter outside
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.

Window function vs GROUP BY vs DISTINCT ON

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.

Common Mistakes
  • 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 and ORDER 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 BY was wanted and then adding DISTINCT on 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 to ROWS — every order that day becomes a peer, so the running total is a daily total repeated.
Best Practices
  • State the frame explicitly with ROWS BETWEEN on any window that has an ORDER BY and computes a running value, rather than inheriting RANGE by accident.
  • Define the window once in a WINDOW clause 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 BY then ORDER BY column order when a window query runs often, and confirm the Sort node has left the plan.
  • Compute ranks in a subquery or CTE and filter on them in the enclosing query, because WHERE runs before any window is evaluated.
  • Use FILTER for conditional aggregates over a window instead of CASE expressions, and keep it to aggregates, which are the only functions that accept it.
  • Pick row_number(), rank() or dense_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.
Comparable toolsMySQL 8+ window functions, same standard syntaxOracle the deepest analytic-function set of the groupSQL Server full window support, different frame performanceSQLite 3.25+ windows in an embedded engine

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