SQL LATERAL JOIN: Correlated Subqueries in FROM Explained
LATERAL is a primitive, not a convenience. It belongs in the same architectural tier as window functions and recursive CTEs: the pieces you reach for when the normal join model stops describing the shape of the data. Think of it as the pipeline stage that turns a driving table into a row generator and lets a parameterized subquery run once per emitted tuple, with full visibility into the prior stage.
What LATERAL Does
In a normal FROM clause, each table or subquery is independent. A subquery in FROM cannot see columns from other items in the same FROM list. LATERAL removes that restriction. It tells the engine: 'Evaluate this subquery once per row from the left side, and give it access to that row's columns.'
Architecturally, this is the same shape as a correlated subquery, relocated into the FROM pipeline so it can contribute rows instead of scalars. A SELECT-clause correlated subquery collapses to one value per row. LATERAL decouples that limit: the parameterized subquery becomes a set-returning function of its input row, and the resulting fan-out feeds directly into downstream joins and aggregates.
Know Joins the way the interviewer who asks it knows it.
PostgreSQL LATERAL Syntax
PostgreSQL uses the LATERAL keyword directly in the FROM clause. You can combine it with CROSS JOIN, LEFT JOIN, or INNER JOIN. The subquery gets an alias, and the join condition (if needed) goes in the ON clause.
Without LATERAL vs With LATERAL
-- WITHOUT LATERAL: this fails
-- The subquery cannot reference d.id
SELECT d.name, recent.total
FROM departments d
JOIN (
SELECT department_id, SUM(amount) AS total
FROM orders
WHERE department_id = d.id -- ERROR: d is not visible
GROUP BY department_id
) recent ON true;
-- WITH LATERAL: this works
SELECT d.name, recent.total
FROM departments d
CROSS JOIN LATERAL (
SELECT SUM(amount) AS total
FROM orders
WHERE department_id = d.id
AND order_date >= CURRENT_DATE - INTERVAL '30 days'
) recent;Interview note: If an interviewer asks what LATERAL means, start with this: 'It lets a subquery in FROM see columns from the tables listed before it. It runs once per row from the left side.' That is the complete mental model in two sentences.
Use Case: Top-N Per Group
The top-N per group problem appears in interviews constantly. 'Find the 3 most recent orders per customer.' 'Get the 5 highest-paid employees per department.' Window functions (ROW_NUMBER + filter) solve this, but LATERAL can be more efficient when an index exists on the sort column.
Top 2 Ad Campaigns by Spend
Two campaigns. Most of the budget.
Pulled from debriefs where SQL was the gate.
PostgreSQL LATERAL Syntax
-- CROSS JOIN LATERAL: exclude rows where subquery returns nothing
SELECT u.name, latest.order_id, latest.amount
FROM users u
CROSS JOIN LATERAL (
SELECT order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY order_date DESC
LIMIT 3
) latest;
-- LEFT JOIN LATERAL: keep all users, even those with no orders
SELECT u.name, latest.order_id, latest.amount
FROM users u
LEFT JOIN LATERAL (
SELECT order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY order_date DESC
LIMIT 3
) latest ON true;Use Case: Unnesting Arrays and JSON
When a column stores an array or JSON array, LATERAL JOIN with a set-returning function (like unnest or json_array_elements) expands each element into its own row. This pattern is essential for normalizing semi-structured data stored in PostgreSQL arrays or JSONB columns.
SQL Server Equivalent: CROSS APPLY / OUTER APPLY
-- SQL Server: CROSS APPLY = CROSS JOIN LATERAL
SELECT u.name, latest.order_id, latest.amount
FROM users u
CROSS APPLY (
SELECT TOP 3 order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY order_date DESC
) latest;
-- SQL Server: OUTER APPLY = LEFT JOIN LATERAL
SELECT u.name, latest.order_id, latest.amount
FROM users u
OUTER APPLY (
SELECT TOP 3 order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY order_date DESC
) latest;Window Function vs LATERAL for Top-N
-- Window function: scans all orders, then filters
WITH ranked AS (
SELECT
user_id,
order_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC
) AS rn
FROM orders
)
SELECT user_id, order_id, amount
FROM ranked
WHERE rn <= 3;
-- LATERAL: uses index to fetch only 3 rows per user
SELECT u.id, latest.order_id, latest.amount
FROM users u
CROSS JOIN LATERAL (
SELECT order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY order_date DESC
LIMIT 3
) latest;Interview note: If you solve top-N with ROW_NUMBER, mention LATERAL as an alternative and explain the performance tradeoff. This shows depth. If you only know one approach, go with ROW_NUMBER since it works on all engines.
Unnesting Arrays and JSON
-- Unnest tags array: one row per product-tag combination
SELECT p.id, p.name, t.tag
FROM products p
CROSS JOIN LATERAL unnest(p.tags) AS t(tag);
-- Unnest JSONB array
SELECT
e.id,
e.name,
skill.value ->> 'name' AS skill_name,
(skill.value ->> 'years')::INT AS years
FROM employees e
CROSS JOIN LATERAL jsonb_array_elements(e.skills) AS skill(value);
-- Generate a row per month for each subscription's active period
SELECT
s.id,
s.plan,
month.dt AS active_month
FROM subscriptions s
CROSS JOIN LATERAL generate_series(
s.start_date,
COALESCE(s.end_date, CURRENT_DATE),
INTERVAL '1 month'
) AS month(dt);LATERAL vs Correlated Subquery vs Window Function
| Feature | LATERAL | Correlated Subquery | Window Function |
|---|---|---|---|
| Position | FROM clause | SELECT or WHERE | SELECT |
| Returns | Multiple rows + columns | Single value | Single value per row |
| LIMIT per group | Yes (native) | Awkward | Via CTE + filter |
| Set-returning functions | Yes | No | No |
| Engine support | PG, Oracle, MySQL 8.0.14+, SS (as APPLY) | All engines | All modern engines |
3 LATERAL JOIN Interview Questions
LATERAL shows up in senior-level interviews and in any interview that asks top-N per group. Knowing the pattern gives you an alternative when the window function solution feels clunky.
For each department, find the 3 most recent hires. Use LATERAL.
FROM departments d CROSS JOIN LATERAL (SELECT * FROM employees WHERE department_id = d.id ORDER BY hire_date DESC LIMIT 3) recent. Mention that an index on (department_id, hire_date DESC) makes this efficient.
Explain the difference between LATERAL JOIN and CROSS APPLY. When would you use each?
CROSS APPLY is SQL Server syntax for the same concept. CROSS APPLY = CROSS JOIN LATERAL. OUTER APPLY = LEFT JOIN LATERAL ... ON true. Oracle supports both syntaxes.
You have a table with a JSONB column storing an array of skill objects. Write a query that returns one row per employee-skill pair.
CROSS JOIN LATERAL jsonb_array_elements(e.skills) AS skill(value). Access fields with value ->> 'name'. Mention that PostgreSQL treats set-returning functions in FROM as implicitly LATERAL.
LATERAL JOIN FAQ
What does LATERAL mean in SQL?+
What is the difference between LATERAL JOIN and CROSS APPLY?+
When should I use a LATERAL JOIN instead of a window function?+
Make LATERAL a default, not an escape hatch
- 01
Active recall beats re-reading by 50%
Cognitive-science meta-reviews (Dunlosky et al., 2013) rank practice testing as a top-tier study technique, while re-reading and highlighting rank near the bottom
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
Five problem shapes cover 80% of data engineer loops
Dedup, sessionization, top-N-per-group, slowly-changing dimensions, partition tricks. Writing the shapes by hand turns the unfamiliar into pattern recognition