LATERAL joins
LATERAL lets a subquery in FROM reference earlier FROM items, so it runs once per outer row. It's the clean way to do top-N-per-group and to unnest set-returning functions — usually one pass, beating correlated scalar subqueries.
“Show each user’s 3 most recent orders” sounds trivial until you try it. A GROUP BY can’t return whole rows, only aggregates. A window function works but reads every order to rank them. The team’s first version fired one query per user from application code — the N+1 pattern — and the endpoint took 4 seconds for 800 users. The one-query rewrite that brought it to 90 ms used a feature most engineers never reach for: LATERAL. It is the cleanest tool for “top-N per group”, and once you have it you stop writing per-row loops.
What LATERAL changes about FROM
Before you reach for an N+1 loop or stack three correlated subqueries, ask yourself: does each result row depend on the current value of an outer row? If yes, LATERAL is the tool that makes it one query instead of many. Normally, the table expressions in a FROM clause are independent: FROM users u, orders o lets o know nothing about u — the join condition links them afterward. A subquery in FROM is the same; it can’t see the other tables. LATERAL removes that wall. A LATERAL subquery (or function) in FROM may reference columns from FROM items that appear before it, and it is evaluated once per row of those earlier items.
That’s the whole idea: a correlated subquery, but in FROM instead of SELECT or WHERE, so it can return multiple columns and multiple rows per outer row rather than a single scalar.
-- For each user, their 3 most recent orders — whole rows, not aggregates
SELECT u.id, u.name, recent.id AS order_id, recent.created_at
FROM users u
CROSS JOIN LATERAL (
SELECT o.id, o.created_at
FROM orders o
WHERE o.user_id = u.id -- references u — only legal because of LATERAL
ORDER BY o.created_at DESC
LIMIT 3
) AS recent;The inner subquery references u.id, which is only allowed because of LATERAL. For each user row, Postgres runs that little “top 3 orders for this user” query and joins the (up to 3) results back. This is the canonical top-N-per-group solution, and there is no concise non-lateral equivalent that returns whole rows.
CROSS vs LEFT JOIN LATERAL, and set-returning functions
CROSS JOIN LATERAL (...) drops an outer row if its subquery returns nothing — a user with zero orders disappears. To keep every outer row (NULL-extended when the subquery is empty), use LEFT JOIN LATERAL (...) ON true. The ON true is required syntax: a lateral left join still needs an ON, and the correlation already lives inside the subquery, so the join condition is just “always.”
LATERAL also shines with set-returning functions — functions that return many rows, like jsonb_array_elements or generate_series. To explode a JSONB array column into rows, one per element, per parent row:
-- One row per tag in each product's JSONB tags array
SELECT p.id, tag.value AS tag
FROM products p
CROSS JOIN LATERAL jsonb_array_elements_text(p.tags) AS tag(value);The function call references p.tags, which is implicitly lateral when a function in FROM references an earlier table — Postgres even lets you omit the keyword for functions, but writing it is clearer. This unnest-per-row pattern is everywhere once you store arrays or JSON (unit 06 goes deep on JSONB).
Why LATERAL often beats a correlated scalar subquery
You can sometimes fake per-row results with correlated scalar subqueries in SELECT — but each one returns a single value and you need a separate subquery per column, each re-scanning the same orders. Want the latest order’s id and date and total? That’s three correlated subqueries, three scans. A single LATERAL returns all three columns from one evaluation per row. Fewer scans, fewer round trips than the application-side N+1 loop, and the planner can choose an index-driven nested loop where the inner LIMIT 3 reads only a few index entries per user — turning the 4-second N+1 endpoint into a sub-100 ms single query.
▸Why this works
When is LATERAL the wrong tool? When you genuinely need a ranking or running computation over all rows, a window function (unit 04) is often cleaner and can be a single sequential pass. The rough rule: LATERAL excels at small top-N-per-group (LIMIT k per outer row) because an indexed nested loop reads only k entries per group; a window function excels when you need a value derived from the whole partition (rank, running total) and would otherwise rescan. For top-3-recent on an indexed (user_id, created_at), LATERAL with LIMIT 3 is typically the cheaper plan; for “rank every order within its user” it’s the window function.
What does LATERAL specifically enable that a normal FROM subquery cannot?
You use CROSS JOIN LATERAL to get each user's top 3 orders, but users with zero orders vanish from the result. How do you keep them?
Fill in the blank: a LATERAL subquery is evaluated once per _______ row of the earlier FROM items, and it may reference that row's columns — which is exactly why it can return each user's top-N orders.
- 01In one sentence, what is a LATERAL join and how is it evaluated?
- 02Write the shape of a top-3-orders-per-user query with LATERAL, and say how to keep users with no orders.
- 03Why does one LATERAL often outperform several correlated scalar subqueries or an N+1 application loop?
LATERAL lifts the wall between FROM items: a LATERAL subquery or function may reference columns of the items before it and is evaluated once per outer row, making it a correlated subquery in FROM that can return many rows and columns per row. Its headline use is top-N-per-group — CROSS JOIN LATERAL (SELECT ... WHERE o.user_id = u.id ORDER BY created_at DESC LIMIT 3) — which has no concise whole-row equivalent without it. Use CROSS JOIN LATERAL to drop outer rows with an empty subquery, or LEFT JOIN LATERAL (...) ON true to keep them NULL-extended. LATERAL also unnests set-returning functions like jsonb_array_elements and generate_series, one element-row per parent. It wins over correlated scalar subqueries (one evaluation returns all columns, not one scan per field) and over the application-side N+1 loop (a single query the planner can run as an indexed nested loop reading only k entries per group). The dividing line with window functions: LATERAL for small per-group top-N over an index; a window function (unit 04) when you need a value computed across the whole partition. Now when you need each group’s top-N rows in a single query, you’ll reach for CROSS JOIN LATERAL (... ORDER BY ... LIMIT N) instead of firing one query per group from application code.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.