A plain subquery in FROM is sealed off: it cannot see columns of its sibling tables. LATERAL lifts that restriction — a subquery on the right of a join may reference columns of tables listed to its left. This turns the FROM list into a loop: for each outer row the subquery is evaluated afresh.
Why LATERAL exists
Compare two subqueries. The first is ordinary, and it will not compile because o tries to reach u.id from a table declared outside it:
SELECT u.id, o.amount
FROM users AS u,
(SELECT amount FROM orders WHERE user_id = u.id) AS o;
Add LATERAL and the subquery gains access to u.id. PostgreSQL runs the right side once per users row, substituting that row's values:
SELECT u.id, o.amount
FROM users AS u,
LATERAL (SELECT amount FROM orders WHERE user_id = u.id) AS o;
Key properties:
- You may only reference tables that appear to the left in the
FROM list.
- A comma in
FROM before LATERAL is a CROSS JOIN LATERAL.
- The subquery runs row by row, so an
ORDER BY and LIMIT tied to the outer row are legal inside it.
Top-N per group
This is the signature LATERAL move. You want the two most expensive orders for each user. A window function can do it too, but LATERAL reads more directly and lets you LIMIT right inside:
SELECT u.id, u.email, top.id AS order_id, top.amount
FROM users AS u
JOIN LATERAL (
SELECT id, amount
FROM orders
WHERE user_id = u.id
ORDER BY amount DESC
LIMIT 2
) AS top ON true;
The subquery fires once per user and yields at most two rows. The ON true is required: JOIN syntax demands an ON, and the join logic already lives inside the subquery (WHERE user_id = u.id).
A subtle but important point. With INNER JOIN LATERAL or the comma form, users with no orders drop out: the subquery returned zero rows, so the outer row vanishes.
SELECT u.id, top.amount
FROM users AS u,
LATERAL (SELECT amount FROM orders WHERE user_id = u.id
ORDER BY amount DESC LIMIT 2) AS top;
To keep every user, use LEFT JOIN LATERAL ... ON true. For users with no orders the subquery columns become NULL, but the row itself stays:
SELECT u.id, top.amount
FROM users AS u
LEFT JOIN LATERAL (
SELECT amount FROM orders WHERE user_id = u.id
ORDER BY amount DESC LIMIT 2
) AS top ON true;
Gotcha: ON true in a LEFT JOIN LATERAL is deceptive. It looks like a constant-true predicate that makes LEFT pointless, but it does not: the outer row survives with NULLs only when the subquery returns zero rows. The correlation is carried by the inner WHERE, not by ON. Put a real condition in ON and it filters after LATERAL expands, breaking the row-preserving behavior.
Set-returning functions in LATERAL
LATERAL shines with functions like generate_series, unnest, or jsonb_array_elements, which need a value from a neighboring column. Generate one row per month since each user signed up:
SELECT u.id, m.month
FROM users AS u
CROSS JOIN LATERAL generate_series(
date_trunc('month', u.created_at),
date_trunc('month', now()),
interval '1 month'
) AS m(month);
Before a function call in FROM the keyword LATERAL is optional — for table functions it is implied. Writing it explicitly is still worthwhile: the code states plainly that there is a correlation on the right.
MySQL and ClickHouse
- MySQL supports
LATERAL from 8.0.14, with the same syntax: JOIN LATERAL (...) ON true. On older versions the top-N task is solved with variables or window functions (from 8.0).
- ClickHouse has no general
LATERAL. Top-N per group is done with arrayJoin and functions like groupArraySorted, or with LIMIT BY:
SELECT user_id, id, amount
FROM orders
ORDER BY amount DESC
LIMIT 2 BY user_id;
LIMIT N BY is a compact stand-in for the top-N-per-group that PostgreSQL assembles from LATERAL. Keep the link in mind: a correlated subquery in FROM is exactly LATERAL, and its bread and butter is top-N and expanding set-returning functions.
A plain subquery in
FROMis sealed off: it cannot see columns of its sibling tables.LATERALlifts that restriction — a subquery on the right of a join may reference columns of tables listed to its left. This turns theFROMlist into a loop: for each outer row the subquery is evaluated afresh.Why LATERAL exists
Compare two subqueries. The first is ordinary, and it will not compile because
otries to reachu.idfrom a table declared outside it:-- Fails: subquery cannot see u.id SELECT u.id, o.amount FROM users AS u, (SELECT amount FROM orders WHERE user_id = u.id) AS o;Add
LATERALand the subquery gains access tou.id. PostgreSQL runs the right side once perusersrow, substituting that row's values:SELECT u.id, o.amount FROM users AS u, LATERAL (SELECT amount FROM orders WHERE user_id = u.id) AS o;Key properties:
FROMlist.FROMbeforeLATERALis aCROSS JOIN LATERAL.ORDER BYandLIMITtied to the outer row are legal inside it.Top-N per group
This is the signature
LATERALmove. You want the two most expensive orders for each user. A window function can do it too, butLATERALreads more directly and lets youLIMITright inside:SELECT u.id, u.email, top.id AS order_id, top.amount FROM users AS u JOIN LATERAL ( SELECT id, amount FROM orders WHERE user_id = u.id ORDER BY amount DESC LIMIT 2 ) AS top ON true;The subquery fires once per user and yields at most two rows. The
ON trueis required:JOINsyntax demands anON, and the join logic already lives inside the subquery (WHERE user_id = u.id).LEFT JOIN LATERAL vs the comma form
A subtle but important point. With
INNER JOIN LATERALor the comma form, users with no orders drop out: the subquery returned zero rows, so the outer row vanishes.-- Users with zero orders disappear SELECT u.id, top.amount FROM users AS u, LATERAL (SELECT amount FROM orders WHERE user_id = u.id ORDER BY amount DESC LIMIT 2) AS top;To keep every user, use
LEFT JOIN LATERAL ... ON true. For users with no orders the subquery columns becomeNULL, but the row itself stays:SELECT u.id, top.amount FROM users AS u LEFT JOIN LATERAL ( SELECT amount FROM orders WHERE user_id = u.id ORDER BY amount DESC LIMIT 2 ) AS top ON true;Set-returning functions in LATERAL
LATERALshines with functions likegenerate_series,unnest, orjsonb_array_elements, which need a value from a neighboring column. Generate one row per month since each user signed up:SELECT u.id, m.month FROM users AS u CROSS JOIN LATERAL generate_series( date_trunc('month', u.created_at), date_trunc('month', now()), interval '1 month' ) AS m(month);Before a function call in
FROMthe keywordLATERALis optional — for table functions it is implied. Writing it explicitly is still worthwhile: the code states plainly that there is a correlation on the right.MySQL and ClickHouse
LATERALfrom 8.0.14, with the same syntax:JOIN LATERAL (...) ON true. On older versions the top-N task is solved with variables or window functions (from 8.0).LATERAL. Top-N per group is done witharrayJoinand functions likegroupArraySorted, or withLIMIT BY:-- ClickHouse: top-2 orders per user SELECT user_id, id, amount FROM orders ORDER BY amount DESC LIMIT 2 BY user_id;LIMIT N BYis a compact stand-in for the top-N-per-group that PostgreSQL assembles fromLATERAL. Keep the link in mind: a correlated subquery inFROMis exactlyLATERAL, and its bread and butter is top-N and expanding set-returning functions.