LATERAL joins: a subquery per row
Part of the free LogicWiz SQL & Databases course, Module 12: CTEs and recursive queries. The queries and practice run in the app on real Postgres.
The store's home screen shows each customer their two most recent orders. You could rank every order with a window function and filter, but the question really says: for each customer, fetch two orders. Can SQL run a small query once per row?
You'll learn to
- Explain what LATERAL lets a subquery in FROM see
- Write top-N per group with JOIN LATERAL and ORDER BY ... LIMIT
- Keep rows with no match using LEFT JOIN LATERAL ... ON true
- Choose between LATERAL and a window function, and say why in an interview
Learn
A subquery that can see the row on its left
A normal subquery in FROM (a derived table) is blind: it cannot use columns of the other tables in the FROM list. It runs once, on its own.
Put LATERAL in front and it can. For each row on its left, Postgres runs the subquery again with that row's values. Think of it as a for-each loop:
for each customer c:
run: SELECT ... FROM orders WHERE customer_id = c.customer_id ... LIMIT 2
Because the subquery runs per row, ORDER BY ... LIMIT n inside it means n rows per customer, not n rows in total. That is top-N per group.
Each customer's latest order
SELECT c.name, o.order_id, o.ordered_at
FROM customers c
JOIN LATERAL (
SELECT order_id, ordered_at
FROM orders
WHERE orders.customer_id = c.customer_id
ORDER BY ordered_at DESC
LIMIT 1
) o ON true
ORDER BY c.name;
The subquery uses c.customer_id from the left side, which only LATERAL allows. ON true is there because JOIN needs an ON, and the real matching already happens inside the subquery. 12 customers go in, 10 rows come out: Dana Cohen and Adam Novak have no orders.
Two latest orders, keeping customers with none
SELECT c.name, o.order_id, o.status
FROM customers c
LEFT JOIN LATERAL (
SELECT order_id, ordered_at, status
FROM orders
WHERE orders.customer_id = c.customer_id
ORDER BY ordered_at DESC
LIMIT 2
) o ON true
ORDER BY c.name, o.ordered_at DESC;
LEFT JOIN LATERAL ... ON true keeps a customer even when the subquery returns nothing, with NULLs on the right. Victor Petrov has one order, so he gets one row.
What the subquery returns for a few customers (LIMIT 2)
| Left row | Subquery returns | JOIN LATERAL | LEFT JOIN LATERAL |
|---|---|---|---|
| Alex Carter | 1017, 1009 (2 of his 4) | 2 rows | 2 rows |
| Victor Petrov | 1014 | 1 row | 1 row |
| Dana Cohen | nothing | dropped | 1 row with NULLs |
The subquery runs once per customer. LIMIT 2 cuts each customer's list separately.
LATERAL or a window function?
| JOIN LATERAL ... LIMIT n | ROWNUMBER / RANK in a subquery |
|---|---|
| Reads like the question: "for each X, the top n Y" | Rank every row, then keep rank <= n |
| LIMIT cuts ties, like ROWNUMBER | RANK or DENSERANK can keep ties |
| With an index on (customerid, orderedat), reads only n rows per customer | Reads and sorts every row of the table |
| Best: few groups, many rows each, a good index | Best: most rows are needed anyway, or you need ties |
| Postgres, MySQL 8.0.14+; SQL Server and Oracle write CROSS APPLY / OUTER APPLY | Every modern database |
Two classic slips. Forgetting ORDER BY inside the subquery: LIMIT 1 then returns any row, not the latest. Using JOIN when every left row must appear: a customer with no orders silently disappears. Write LEFT JOIN LATERAL (...) alias ON true.
Interviewers ask "top N per group" expecting a window function first. Give it, then add: "On Postgres with an index on (group, sort column) and few groups, I would use LATERAL with ORDER BY ... LIMIT N, because it reads only N rows per group." They also like "latest row per user" and "nearest match per row" (the closest earlier price, the last status before a date). If they use SQL Server, say CROSS APPLY and OUTER APPLY.
In one line
JOIN LATERAL (SELECT ... WHERE x = left.x ORDER BY ... LIMIT n) t ON true runs a subquery per row: top-N per group that reads like the question; use LEFT to keep rows with no match.
Interview corner
What is a LATERAL join?
A join to a subquery that can reference columns of the tables before it in FROM. Postgres evaluates it for each left row, like a for-each loop. SELECT c.name, o.* FROM customers c JOIN LATERAL (SELECT * FROM orders WHERE customer_id = c.customer_id ORDER BY ordered_at DESC LIMIT 3) o ON true; gives the 3 latest orders per customer.
LATERAL vs ROW_NUMBER for top-N per group: which is faster?
It depends on the shape. With few groups, many rows per group and an index on (group, sort column), LATERAL with LIMIT reads only N index entries per group. When you need most rows anyway, or need ties kept with RANK, the window function over one scan is usually better. Say both and pick by the data.
How do you keep left rows when the LATERAL subquery returns nothing?
Use LEFT JOIN LATERAL (...) t ON true. A plain JOIN LATERAL (or a comma with LATERAL) drops those rows, just like an inner join.
What is CROSS APPLY?
SQL Server and Oracle's name for LATERAL. CROSS APPLY is JOIN LATERAL ... ON true, and OUTER APPLY is LEFT JOIN LATERAL ... ON true. MySQL supports LATERAL from 8.0.14.