LATERAL joins and correlated subqueries

Maria Garcia Feb 2026
2 tabs
-- Basic LATERAL join
SELECT
  u.username,
  recent.order_id,
  recent.total,
  recent.created_at
FROM users u
CROSS JOIN LATERAL (
  SELECT id AS order_id, total, created_at
  FROM orders
  WHERE user_id = u.id
  ORDER BY created_at DESC
  LIMIT 3
) AS recent;
-- Returns up to 3 most recent orders per user

-- LEFT JOIN LATERAL (include users with no orders)
SELECT
  u.username,
  recent.order_id,
  recent.total
FROM users u
LEFT JOIN LATERAL (
  SELECT id AS order_id, total
  FROM orders
  WHERE user_id = u.id
  ORDER BY created_at DESC
  LIMIT 3
) AS recent ON true;

-- Compare to traditional approach (slower)
SELECT
  u.username,
  o.id AS order_id,
  o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IN (
  SELECT id
  FROM orders o2
  WHERE o2.user_id = u.id
  ORDER BY created_at DESC
  LIMIT 3
);

-- Top N per group
SELECT
  c.name AS category,
  p.name AS product,
  p.price
FROM categories c
CROSS JOIN LATERAL (
  SELECT name, price
  FROM products
  WHERE category_id = c.id
  ORDER BY price DESC
  LIMIT 5
) p;

-- Multiple LATERAL joins
SELECT
  u.username,
  recent_order.total AS last_order_total,
  favorite_product.name AS favorite_product
FROM users u
LEFT JOIN LATERAL (
  SELECT total
  FROM orders
  WHERE user_id = u.id
  ORDER BY created_at DESC
  LIMIT 1
) recent_order ON true
LEFT JOIN LATERAL (
  SELECT p.name
  FROM orders o
  JOIN order_items oi ON o.id = oi.order_id
  JOIN products p ON oi.product_id = p.id
  WHERE o.user_id = u.id
  GROUP BY p.id, p.name
  ORDER BY COUNT(*) DESC
  LIMIT 1
) favorite_product ON true;

-- LATERAL with aggregates
SELECT
  u.username,
  stats.order_count,
  stats.total_spent,
  stats.avg_order
FROM users u
CROSS JOIN LATERAL (
  SELECT
    COUNT(*) AS order_count,
    SUM(total) AS total_spent,
    AVG(total) AS avg_order
  FROM orders
  WHERE user_id = u.id
) stats
WHERE stats.order_count > 0;

-- Set-returning function in LATERAL
SELECT
  u.username,
  day.date,
  day.order_count
FROM users u
CROSS JOIN LATERAL (
  SELECT
    DATE(created_at) AS date,
    COUNT(*) AS order_count
  FROM orders
  WHERE user_id = u.id
    AND created_at >= CURRENT_DATE - INTERVAL '7 days'
  GROUP BY DATE(created_at)
) day;

-- Generate series in LATERAL
SELECT
  p.name,
  date.day,
  COALESCE(sales.daily_sales, 0) AS sales
FROM products p
CROSS JOIN LATERAL
  generate_series(
    CURRENT_DATE - INTERVAL '30 days',
    CURRENT_DATE,
    INTERVAL '1 day'
  ) AS date(day)
LEFT JOIN LATERAL (
  SELECT SUM(oi.quantity * oi.price) AS daily_sales
  FROM order_items oi
  JOIN orders o ON oi.order_id = o.id
  WHERE oi.product_id = p.id
    AND DATE(o.created_at) = date.day
) sales ON true
WHERE p.id = 1
ORDER BY date.day;
2 files · sql Explain with highlit

LATERAL joins enable correlated subqueries in FROM clause. Each row can reference previous table columns. I use LATERAL for top-N-per-group queries. CROSS JOIN LATERAL iterates for each row. LEFT JOIN LATERAL includes rows without matches. Understanding LATERAL unlocks powerful query patterns. Function calls in LATERAL receive row values. Set-returning functions expand to multiple rows. LATERAL replaces complex subqueries with clearer syntax. Proper use improves both performance and readability. Essential for complex analytics, related records, rankings. LATERAL is PostgreSQL's secret weapon for advanced queries.