The SQL Superpower You’re Not Using: A Guide to LATERAL Joins
If you’ve ever felt limited by standard INNER or LEFT joins, you aren’t alone. Traditional joins act like a static Venn diagram—they combine two tables based on a predefined condition, but they can't "talk" to each other row-by-row during the process.
Enter the LATERAL JOIN. Introduced in PostgreSQL 9.3 and now a staple of modern SQL, it is often described as a SQL foreach loop.
What is a LATERAL Join?
A LATERAL join allows a subquery in the FROM clause to reference columns from preceding tables in the same FROM list.
In a standard join, subqueries are evaluated in isolation. They don't know the "current row" of the main table exists. With the LATERAL keyword, you break that wall, allowing the subquery to calculate results dynamically for every single row of the left-hand table.
Top 3 Use Cases
The "Top N" Per Category Problem
This is the classic use case. Imagine you have a Categories table and a Products table. You want to find the 3 most expensive products for every category.
- Without Lateral: You’d need complex Window Functions (
ROW_NUMBER()) and a wrapper query. - With Lateral: You simply write a subquery that selects the top 3 products and "connects" it to the category ID laterally.
Calculations on Calculated Columns
If you calculate a value in your query (like a complex tax formula) and want to use that result in another calculation in the same row, standard SQL makes you repeat the code or use a CTE.
A LATERAL join lets you define the calculation once and reference it like a variable.
Expanding JSONB or Arrays
If you store data in JSONB blobs or arrays, LATERAL is your best friend for "un-nesting" that data while keeping it attached to its parent record.
LATERAL in Action: The Syntax
Here is how it looks in practice when fetching the most recent order for every customer:
SELECT
c.customer_name,
recent_order.order_date,
recent_order.total_amount
FROM customers c
CROSS JOIN LATERAL (
SELECT order_date, total_amount
FROM orders o
WHERE o.customer_id = c.id -- This reference is only possible with LATERAL
ORDER BY order_date DESC
LIMIT 1
) AS recent_order;
Rewriting a Complex Query: The Subscription Problem
Fetching each user's name, their most recent payment, and the difference between that and their previous payment.
The "Old" Way: Window Functions & CTEs
Before LATERAL, you’d likely use Common Table Expressions (CTEs) and LAG() to peek at previous rows. It works, but it’s dense and requires multiple scans of the data.
WITH RankedPayments AS (
SELECT
user_id,
amount,
LAG(amount) OVER (PARTITION BY user_id ORDER BY created_at DESC) as prev_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rn
FROM billing_events
)
SELECT
u.name,
rp.amount AS current_payment,
rp.prev_amount AS last_payment,
(rp.amount - rp.prev_amount) AS delta
FROM users u
JOIN RankedPayments rp ON u.id = rp.user_id
WHERE rp.rn = 1;
The "LATERAL" Way: Clean & Modular
With a LATERAL join, you treat the "latest two payments" as a little package you fetch for every user. It reads like a story: "For every user, find their last two bills, then calculate the difference."
SELECT
u.name,
bills.current_payment,
bills.last_payment,
(bills.current_payment - bills.last_payment) AS delta
FROM users u
LEFT JOIN LATERAL (
SELECT
amount AS current_payment,
-- Sub-subquery to get the second most recent bill
(SELECT amount FROM billing_events b2
WHERE b2.user_id = u.id
ORDER BY created_at DESC LIMIT 1 OFFSET 1) AS last_payment
FROM billing_events b1
WHERE b1.user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) bills ON TRUE;
Why the LATERAL version wins here:
- Index Optimization: If
user_idandcreated_atare indexed, Postgres can perform a very fast "Index Scan" for each user rather than sorting the entirebilling_eventstable to partition it. - Readability: You don't have to keep track of
WHERE rn = 1at the very end of your query; the logic is encapsulated within the join. - Flexibility: If you suddenly need to add a third column (like the average of all previous payments for just that user), you can drop that logic right into the
LATERALblock without breaking the rest of the query.
The Bottom Line
The LATERAL join is a tool that moves SQL closer to procedural programming logic without sacrificing the declarative power of the database. It simplifies complex queries and, when paired with the right indexes, can significantly boost performance for per-row calculations.