How to Use LATERAL JOIN in PostgreSQL

LATERAL JOIN is PostgreSQL's solution to a fundamental limitation in SQL: standard subqueries in the FROM clause cannot reference columns from other tables in the same FROM list. This restriction...

Key Insights

  • LATERAL JOIN allows subqueries to reference columns from preceding tables in the FROM clause, effectively creating a “for-each loop” over rows that standard JOINs cannot achieve
  • Use LATERAL for top-N-per-group queries, unnesting arrays with row-specific context, and calling set-returning functions with parameters from each row
  • While powerful, LATERAL can be slower than window functions for aggregations—always check execution plans and consider alternatives for performance-critical queries

Understanding LATERAL JOIN

LATERAL JOIN is PostgreSQL’s solution to a fundamental limitation in SQL: standard subqueries in the FROM clause cannot reference columns from other tables in the same FROM list. This restriction makes certain query patterns either impossible or unnecessarily complex.

Think of LATERAL as converting a subquery into a correlated operation that executes once for each row of the preceding table. It’s essentially a for-each loop in SQL form.

Here’s the problem LATERAL solves:

-- This FAILS - subquery cannot reference o.customer_id
SELECT c.name, recent.order_date
FROM customers c
JOIN (
    SELECT order_date 
    FROM orders 
    WHERE customer_id = c.customer_id  -- ERROR: column "c.customer_id" does not exist
    ORDER BY order_date DESC 
    LIMIT 1
) recent ON true;

-- This WORKS with LATERAL
SELECT c.name, recent.order_date
FROM customers c
JOIN LATERAL (
    SELECT order_date 
    FROM orders 
    WHERE customer_id = c.customer_id
    ORDER BY order_date DESC 
    LIMIT 1
) recent ON true;

The LATERAL keyword enables the subquery to “see” columns from customers c, making correlated queries straightforward and readable.

Basic LATERAL JOIN Syntax

The syntax is simple—just add the LATERAL keyword before your subquery or table function:

SELECT columns
FROM table1 t1
JOIN LATERAL (
    subquery referencing t1.columns
) alias ON join_condition;

The join condition can be ON true when the correlation is already established inside the subquery. Here’s a practical example finding the three most recent orders per customer:

SELECT 
    c.customer_name,
    o.order_id,
    o.order_date,
    o.total_amount
FROM customers c
LEFT JOIN LATERAL (
    SELECT order_id, order_date, total_amount
    FROM orders
    WHERE customer_id = c.customer_id
    ORDER BY order_date DESC
    LIMIT 3
) o ON true
ORDER BY c.customer_name, o.order_date DESC;

Notice the LEFT JOIN LATERAL—this ensures customers without orders still appear in results with NULL values for order columns. Use LEFT JOIN LATERAL when the relationship is optional, just like with regular joins.

LATERAL also works with table-returning functions:

SELECT 
    p.product_name,
    dates.day
FROM products p
JOIN LATERAL generate_series(
    p.launch_date,
    p.launch_date + interval '7 days',
    interval '1 day'
) AS dates(day) ON true;

This generates a row for each day in the week following each product’s launch date.

Common Use Cases

Top-N Per Group Queries

This is LATERAL’s killer application. Getting the top N records per category is awkward with window functions (requiring a CTE or subquery to filter) but natural with LATERAL:

-- Get the 3 highest-paid employees per department
SELECT 
    d.department_name,
    e.employee_name,
    e.salary
FROM departments d
LEFT JOIN LATERAL (
    SELECT employee_name, salary
    FROM employees
    WHERE department_id = d.department_id
    ORDER BY salary DESC
    LIMIT 3
) e ON true
ORDER BY d.department_name, e.salary DESC;

Unnesting Arrays with Context

When you need to unnest arrays or JSONB data while maintaining access to the parent row’s columns:

-- Expand product tags while keeping product information
SELECT 
    p.product_id,
    p.product_name,
    t.tag
FROM products p
JOIN LATERAL unnest(p.tags) AS t(tag) ON true
WHERE t.tag LIKE 'electronics%';

-- Unnest JSONB arrays with row context
SELECT 
    u.user_id,
    u.username,
    addr.value->>'street' AS street,
    addr.value->>'city' AS city
FROM users u
JOIN LATERAL jsonb_array_elements(u.addresses) AS addr(value) ON true;

Complex Calculations Referencing Multiple Columns

LATERAL enables calculations that depend on values from the outer query:

-- Calculate distance from each store to nearby customers
SELECT 
    s.store_name,
    c.customer_name,
    nearby.distance_km
FROM stores s
JOIN LATERAL (
    SELECT 
        customer_name,
        earth_distance(
            ll_to_earth(s.latitude, s.longitude),
            ll_to_earth(c.latitude, c.longitude)
        ) / 1000 AS distance_km
    FROM customers c
    WHERE earth_box(ll_to_earth(s.latitude, s.longitude), 5000) @> ll_to_earth(c.latitude, c.longitude)
    ORDER BY distance_km
    LIMIT 10
) nearby ON true;

LATERAL with Table Functions

PostgreSQL’s set-returning functions become significantly more powerful with LATERAL. You can pass row-specific parameters to generate dynamic result sets:

-- Generate time slots for each appointment based on its duration
SELECT 
    a.appointment_id,
    a.patient_name,
    slots.time_slot
FROM appointments a
JOIN LATERAL generate_series(
    a.start_time,
    a.start_time + (a.duration_minutes || ' minutes')::interval,
    interval '15 minutes'
) AS slots(time_slot) ON true;

-- Create a custom table function and use it with LATERAL
CREATE FUNCTION get_order_stats(cust_id INTEGER)
RETURNS TABLE(
    total_orders BIGINT,
    total_spent NUMERIC,
    avg_order_value NUMERIC
) AS $$
    SELECT 
        COUNT(*),
        SUM(total_amount),
        AVG(total_amount)
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL STABLE;

-- Use the function with LATERAL
SELECT 
    c.customer_name,
    stats.*
FROM customers c
LEFT JOIN LATERAL get_order_stats(c.customer_id) stats ON true;

Performance Considerations

LATERAL JOIN isn’t always the fastest option. It executes the subquery for each row of the left table, which can be expensive. Always compare with alternatives:

-- LATERAL approach for running totals
EXPLAIN ANALYZE
SELECT 
    o.order_id,
    o.order_date,
    running.total
FROM orders o
JOIN LATERAL (
    SELECT SUM(total_amount) AS total
    FROM orders o2
    WHERE o2.customer_id = o.customer_id 
      AND o2.order_date <= o.order_date
) running ON true;

-- Window function approach (usually faster)
EXPLAIN ANALYZE
SELECT 
    order_id,
    order_date,
    SUM(total_amount) OVER (
        PARTITION BY customer_id 
        ORDER BY order_date
    ) AS total
FROM orders;

In my testing, the window function approach is typically 3-5x faster for running calculations. However, LATERAL wins when you need LIMIT per group or complex filtering that window functions can’t express cleanly.

When LATERAL performs well:

  • Top-N per group with proper indexes on the correlated columns
  • Unnesting operations where you need row context
  • When the left table is small and the LATERAL subquery is selective

When to avoid LATERAL:

  • Simple aggregations (use window functions)
  • When the left table is huge and the subquery isn’t selective
  • If you can express the logic with a simple JOIN and GROUP BY

Always check the execution plan. Look for “Nested Loop” nodes—if the LATERAL subquery shows high execution counts without index usage, you’ll have problems.

Common Pitfalls and Best Practices

CROSS JOIN LATERAL vs JOIN LATERAL

CROSS JOIN LATERAL and JOIN LATERAL ... ON true are functionally identical. Use whichever reads better to you, but be consistent:

-- These are equivalent
SELECT * FROM t1 CROSS JOIN LATERAL (subquery) alias;
SELECT * FROM t1 JOIN LATERAL (subquery) alias ON true;

Column Scoping Mistakes

LATERAL subqueries can only reference tables that appear before them in the FROM clause:

-- WRONG: t2 appears after the LATERAL subquery
SELECT *
FROM t1
JOIN LATERAL (
    SELECT * FROM t3 WHERE t3.id = t2.id  -- ERROR
) sub ON true
JOIN t2 ON t1.id = t2.id;

-- CORRECT: t2 appears before LATERAL
SELECT *
FROM t1
JOIN t2 ON t1.id = t2.id
JOIN LATERAL (
    SELECT * FROM t3 WHERE t3.id = t2.id  -- OK
) sub ON true;

Overusing LATERAL for Readability

Don’t use LATERAL when a simple subquery in SELECT or a window function is clearer:

-- Unnecessarily complex with LATERAL
SELECT c.name, cnt.order_count
FROM customers c
JOIN LATERAL (
    SELECT COUNT(*) AS order_count
    FROM orders WHERE customer_id = c.customer_id
) cnt ON true;

-- Simpler and equally efficient
SELECT 
    c.name,
    COUNT(o.order_id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name;

Use LATERAL when it genuinely simplifies the query or enables functionality you can’t achieve otherwise. For straightforward aggregations, stick with standard JOINs and GROUP BY.

Index Requirements

Ensure the correlated columns are indexed. For top-N queries, you typically need a composite index:

-- For efficient LATERAL top-N queries
CREATE INDEX idx_orders_customer_date 
ON orders(customer_id, order_date DESC);

Without proper indexes, LATERAL queries can trigger sequential scans for every row of the outer table—a performance disaster.

LATERAL JOIN is a powerful tool that solves real problems elegantly. Use it for top-N per group, unnesting with context, and set-returning functions with row-specific parameters. But always validate your choice with execution plans, and don’t reach for LATERAL when simpler SQL constructs will do the job.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.