LATERAL, DISTINCT ON, and GROUPING SETS
Each of the three constructs here replaces a pattern that teams otherwise write as a loop in application code. LATERAL lets a subquery in FROM see the row to its left, which is how "the three most recent orders per customer" becomes one query. DISTINCT ON is the Postgres shortcut for "one row per group". GROUPING SETS computes several levels of aggregation in a single pass where the obvious version is a UNION ALL of three nearly identical queries.
All three exist in Cartwheel's backlog right now. The recent-orders screen fetches the customer list and then issues one query per customer for their last three orders. The operations dashboard finds the current state of each delivery by pulling two hours of delivery_events and keeping the newest row per order_id in a dictionary. The finance report unions three aggregate queries over orders to produce per-city, per-day and overall totals, and the three definitions have already drifted apart once.
LATERAL, a Correlated Subquery in FROM
A subquery in FROM is normally evaluated on its own and cannot cross-reference anything beside it. LATERAL removes that restriction: the sub-select may refer to columns of FROM items that appear before it in the list, or that sit on the left-hand side of the join it is on the right of. Evaluation is exactly what the name suggests — for each row of the source item, the lateral item is evaluated with that row's values, and the results are joined back to the row they were computed from.
SELECT c.id, c.email, r.id AS order_id, r.placed_at, r.total
FROM customers c
LEFT JOIN LATERAL (
SELECT o.id, o.placed_at, o.total
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.placed_at DESC
LIMIT 3
) r ON true
WHERE c.id = ANY(:customer_ids);
The join is LEFT JOIN … ON true rather than a comma or CROSS JOIN, because a cross join drops customers who have never ordered and the screen needs them with an empty list. The plan is a nested loop: for each of the 200 customers on the screen, an index scan on orders (customer_id, placed_at DESC) that reads three rows and stops. That is 600 rows touched out of 40 million. Without that index, the same loop repeats a sequential scan of orders 200 times.
Top-N per Group, Three Ways
The same question has three answers and they do not perform alike. LATERAL with LIMIT does repeated indexed lookups, so its cost scales with the outer set: unbeatable when the outer side is 200 rows and the inner side is indexed. A window function ranking with row_number() OVER (PARTITION BY customer_id ORDER BY placed_at DESC) filtered to <= 3 sorts the whole input once, which is the right shape when you were going to read all of it anyway — a report over every customer, not a screen showing a few. DISTINCT ON handles the special case of N equal to one with a single sort and no join at all.
Which form is right is a question about the data rather than about the syntax — Chapter 9 reads all three plans off EXPLAIN side by side. Three things settle it: how many outer rows there are, whether the inner side is indexed on the join and sort keys, and whether the query needs the whole set ranked or only its head.
DISTINCT ON, First Row per Group
DISTINCT ON (expressions) keeps the first row of each set of rows sharing those expressions. "First" is decided by the query's ORDER BY, and one rule decides whether the query is even legal: the DISTINCT ON expressions must match the leftmost ORDER BY expressions. Without an ORDER BY the row you get from each group is unpredictable, and the query will happily return a different answer tomorrow.
SELECT DISTINCT ON (order_id)
order_id, event_type, occurred_at
FROM delivery_events
WHERE occurred_at >= now() - interval '2 hours'
ORDER BY order_id, occurred_at DESC;
The sort groups the rows by order_id and, within each order, puts the newest event first; DISTINCT ON then keeps the head of each group. Writing ORDER BY occurred_at DESC alone is rejected, and the error is worth reading rather than pattern-matching around: it is saying that DISTINCT ON is defined in terms of the sort, so the sort has to start where the grouping starts. The construct is Postgres-only; porting it means rewriting it as row_number() = 1.
GROUPING SETS, ROLLUP, and CUBE
A GROUP BY clause can define several independent groupings at once, and the result is the UNION ALL of them — computed from one scan instead of three. ROLLUP (a, b) is shorthand for the list and all its prefixes including the empty one, so it produces three levels: per a-and-b, per a, and a grand total. CUBE (a, b, c) produces the full power set, which is 2 to the power of the number of expressions — eight levels for three columns, and worth counting before you write it.
SELECT date_trunc('day', o.placed_at) AS day, c.city, count(*) AS orders, sum(o.total) AS revenue, GROUPING(c.city) AS is_day_total FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.placed_at >= '2026-08-01' GROUP BY ROLLUP (date_trunc('day', o.placed_at), c.city) ORDER BY day, c.city;
Columns that are not part of a given grouping set come back as NULL, which is indistinguishable from a genuine null city until you ask. GROUPING() answers: it returns an integer bitmask over its arguments, with the rightmost argument as the least significant bit, and a bit set to 1 wherever that expression was not part of the grouping that produced this row. Replacing a three-query UNION ALL with this removes two scans of orders and two places where the definition of revenue can drift. The dashboard renders a row with is_day_total = 1 in bold and never has to guess which rows are subtotals.
Set-Returning Functions in FROM
A function that returns a set can be written directly as a FROM item, and LATERAL is implicit there — the function may reference earlier items with no keyword. generate_series manufactures a dense axis, unnest expands an array back into rows, and jsonb_array_elements explodes a document into one row per element. The array case is one line against Cartwheel's products.tags: FROM products p, unnest(p.tags) AS t(tag) gives a row per product per tag, ready to group.
SELECT h.hour, count(d.id) AS events FROM generate_series(date_trunc('hour', now()) - interval '23 hours', date_trunc('hour', now()), interval '1 hour') AS h(hour) LEFT JOIN delivery_events d ON d.occurred_at >= h.hour AND d.occurred_at < h.hour + interval '1 hour' GROUP BY h.hour ORDER BY h.hour;
The 24 rows of the axis exist whether or not any delivery happened in them, so an hour with no events shows up as a zero rather than vanishing from the chart — and an hour that vanishes is exactly the hour you needed to see. One detail decides whether it works: count(d.id) counts matched rows, while count(*) counts the axis row itself and reports 1 for every empty hour.
Choosing Between Them
None of these is cleverer than the others; they produce different plans, and the data decides which plan is right. LATERAL gives repeated indexed lookups and is priced by the size of the outer set. A window function gives one large sort and is priced by the size of the whole input. DISTINCT ON gives one sort and no join. GROUPING SETS gives one scan feeding several aggregation levels.
That closes the read side of Cartwheel's problem list. Three round trips became one query, a dictionary in Python became a sort, and three unioned aggregates became one scan. What is left is the write side: statements that need to hand back what they just changed, and statements that need to move rows between two tables without a window in which the rows exist in neither.
- Writing
ORDER BY occurred_at DESCwithout leading with theDISTINCT ONcolumn — the statement is rejected, and the fix is to understand that the grouping is defined by the sort. - Using
DISTINCT ONwith noORDER BYat all — no error, and the row returned for each group is whichever one the plan happened to produce first. - Pointing a
LATERALsubquery at an inner table with no supporting index — the nested loop repeats a sequential scan once per outer row, which is the classic neat-query-terrible-plan. - Joining a
LATERALitem with a comma orCROSS JOINwhen empty results are meaningful — customers with no orders disappear from the screen entirely instead of showing an empty list. - Emitting a
UNION ALLof three aggregate queries whereROLLUPdoes one scan — three times the I/O onordersand three definitions of revenue that drift apart independently. - Building a report's time axis from the data itself — hours with zero deliveries silently vanish, and
count(*)over a generated axis reports 1 for every empty bucket.
- Use
LATERALfor top-N per group when the outer set is small and the inner table is indexed on the join and sort keys, then confirm the nested loop in the plan. - Write
LEFT JOIN LATERAL (…) ON truewhenever an empty inner result should still return the outer row. - Use
DISTINCT ONfor latest-per-group and keep theORDER BYon the adjacent line, so the prefix rule is visible to whoever edits it next. - Replace stacks of unioned aggregates with
ROLLUPor explicitGROUPING SETS, and tag the subtotal rows withGROUPING()instead of testing forNULL. - Count the grouping sets before writing
CUBE, since each additional expression doubles them. - Generate reporting axes with
generate_seriesand left-join the data onto them, counting a column from the data side rather thancount(*).
Knowledge Check
What does LATERAL allow that a plain subquery in FROM does not?
- Using LIMIT inside a subquery that appears in the FROM clause
- Referencing columns of FROM items that appear before it in the list
- Correlating a subquery in the select list with the row being returned
- Forcing the planner to choose a nested loop over a hash join strategy
Why does SELECT DISTINCT ON (order_id) ... ORDER BY occurred_at DESC fail?
- Because occurred_at must also appear in the DISTINCT ON expression list
- Because the ORDER BY must begin with the DISTINCT ON expressions
- Because DISTINCT ON cannot be combined with a descending sort order
- Because there is no index supporting the requested ordering of rows
The recent-orders screen shows 200 customers with their last three orders each. Which form fits best, and why?
- LATERAL with LIMIT 3, because the cost scales with the 200 outer rows
- A window function ranking, because one sort is cheaper than 200 lookups
- DISTINCT ON with a limit of three, because it needs no join at all
- One query per customer, because each is individually fast and indexed
What does GROUP BY ROLLUP (day, city) produce that three unioned aggregates do not?
- More accurate totals, since the union can double-count overlapping rows
- Three grouping levels from a single scan, with subtotals identifiable
- Every possible combination of the two columns, including city alone
- A cached result the planner can reuse for the next report execution
A dashboard bar chart is missing the hours in which nothing was delivered. What fixes it?
- Left-joining delivery_events to itself so unmatched hours are preserved
- Wrapping the count in coalesce so that missing hours are reported as zero
- Generating the hourly axis and left-joining the events onto it
- Grouping by date_trunc on the hour so every bucket is created explicitly
You got correct