SQL - Lateral Join / CROSS APPLY

• Lateral joins (PostgreSQL) and CROSS APPLY (SQL Server) enable correlated subqueries in the FROM clause, allowing each row from the left table to pass parameters to the right-side table expression

Key Insights

• Lateral joins (PostgreSQL) and CROSS APPLY (SQL Server) enable correlated subqueries in the FROM clause, allowing each row from the left table to pass parameters to the right-side table expression • These constructs eliminate the need for inefficient scalar subqueries in SELECT clauses and enable set-based operations that would otherwise require cursors or multiple queries • Performance gains are substantial when replacing row-by-row operations, particularly for top-N-per-group queries and complex aggregations that depend on values from outer rows

What Are Lateral Joins and CROSS APPLY

Lateral joins break the fundamental SQL rule that table expressions in the FROM clause cannot reference columns from other tables in the same FROM clause. In PostgreSQL, you use the LATERAL keyword. In SQL Server and Azure SQL, you use CROSS APPLY or OUTER APPLY.

Standard join behavior:

-- This fails - subquery cannot reference o.customer_id
SELECT o.order_id, recent.product_name
FROM orders o
JOIN (
    SELECT product_name 
    FROM order_items 
    WHERE order_id = o.order_id  -- ERROR: o not in scope
    LIMIT 1
) recent ON true;

With lateral join (PostgreSQL):

SELECT o.order_id, recent.product_name
FROM orders o
CROSS JOIN LATERAL (
    SELECT product_name 
    FROM order_items 
    WHERE order_id = o.order_id
    LIMIT 1
) recent;

With CROSS APPLY (SQL Server):

SELECT o.order_id, recent.product_name
FROM orders o
CROSS APPLY (
    SELECT TOP 1 product_name 
    FROM order_items 
    WHERE order_id = o.order_id
) recent;

Top-N Per Group Queries

The most common use case is retrieving the top N records for each group. Without lateral joins, you’d use window functions or self-joins, which are often less efficient.

Traditional approach with window functions:

-- PostgreSQL
WITH ranked AS (
    SELECT 
        e.department_id,
        e.employee_name,
        e.salary,
        ROW_NUMBER() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) as rn
    FROM employees e
)
SELECT department_id, employee_name, salary
FROM ranked
WHERE rn <= 3;

With lateral join:

-- PostgreSQL
SELECT d.department_name, top_earners.*
FROM departments d
CROSS JOIN LATERAL (
    SELECT employee_name, salary
    FROM employees e
    WHERE e.department_id = d.department_id
    ORDER BY salary DESC
    LIMIT 3
) top_earners;

SQL Server equivalent:

SELECT d.department_name, top_earners.*
FROM departments d
CROSS APPLY (
    SELECT TOP 3 employee_name, salary
    FROM employees e
    WHERE e.department_id = d.department_id
    ORDER BY salary DESC
) top_earners;

The lateral approach often performs better because it can use indexes more effectively and stops processing once it finds the required number of rows per group.

Replacing Scalar Subqueries

Scalar subqueries in the SELECT clause execute once per row, creating N+1 query problems. Lateral joins execute set-based operations instead.

Inefficient scalar subquery:

-- SQL Server
SELECT 
    c.customer_id,
    c.customer_name,
    (SELECT MAX(order_date) FROM orders WHERE customer_id = c.customer_id) as last_order,
    (SELECT COUNT(*) FROM orders WHERE customer_id = c.customer_id) as order_count,
    (SELECT SUM(total_amount) FROM orders WHERE customer_id = c.customer_id) as total_spent
FROM customers c;

Optimized with CROSS APPLY:

-- SQL Server
SELECT 
    c.customer_id,
    c.customer_name,
    stats.last_order,
    stats.order_count,
    stats.total_spent
FROM customers c
CROSS APPLY (
    SELECT 
        MAX(order_date) as last_order,
        COUNT(*) as order_count,
        SUM(total_amount) as total_spent
    FROM orders
    WHERE customer_id = c.customer_id
) stats;

This executes a single pass through the orders table per customer instead of three separate scans.

OUTER APPLY vs CROSS APPLY

CROSS APPLY behaves like an INNER JOIN—if the applied table expression returns no rows, the left row is excluded. OUTER APPLY behaves like a LEFT JOIN, preserving left rows even when the applied expression returns nothing.

-- SQL Server - CROSS APPLY excludes customers without orders
SELECT c.customer_name, recent.order_date
FROM customers c
CROSS APPLY (
    SELECT TOP 1 order_date
    FROM orders
    WHERE customer_id = c.customer_id
    ORDER BY order_date DESC
) recent;

-- OUTER APPLY includes all customers
SELECT c.customer_name, recent.order_date
FROM customers c
OUTER APPLY (
    SELECT TOP 1 order_date
    FROM orders
    WHERE customer_id = c.customer_id
    ORDER BY order_date DESC
) recent;

PostgreSQL equivalent:

-- LEFT JOIN LATERAL includes all customers
SELECT c.customer_name, recent.order_date
FROM customers c
LEFT JOIN LATERAL (
    SELECT order_date
    FROM orders
    WHERE customer_id = c.customer_id
    ORDER BY order_date DESC
    LIMIT 1
) recent ON true;

Complex Aggregations with Dependencies

Lateral joins excel when aggregations depend on calculations from outer queries.

-- PostgreSQL - Calculate running totals with dynamic windows
SELECT 
    t.transaction_date,
    t.amount,
    running.total_last_30_days
FROM transactions t
CROSS JOIN LATERAL (
    SELECT SUM(amount) as total_last_30_days
    FROM transactions
    WHERE account_id = t.account_id
      AND transaction_date BETWEEN t.transaction_date - INTERVAL '30 days' 
                               AND t.transaction_date
) running
ORDER BY t.transaction_date;

SQL Server version with date arithmetic:

SELECT 
    t.transaction_date,
    t.amount,
    running.total_last_30_days
FROM transactions t
CROSS APPLY (
    SELECT SUM(amount) as total_last_30_days
    FROM transactions
    WHERE account_id = t.account_id
      AND transaction_date BETWEEN DATEADD(day, -30, t.transaction_date) 
                               AND t.transaction_date
) running
ORDER BY t.transaction_date;

Unpivoting with APPLY

Table value constructors combined with APPLY provide elegant unpivoting solutions.

-- SQL Server - Unpivot multiple columns
SELECT 
    p.product_id,
    p.product_name,
    metrics.metric_name,
    metrics.metric_value
FROM products p
CROSS APPLY (
    VALUES 
        ('Views', p.view_count),
        ('Clicks', p.click_count),
        ('Purchases', p.purchase_count)
) metrics(metric_name, metric_value);

PostgreSQL equivalent:

SELECT 
    p.product_id,
    p.product_name,
    metrics.metric_name,
    metrics.metric_value
FROM products p
CROSS JOIN LATERAL (
    VALUES 
        ('Views', p.view_count),
        ('Clicks', p.click_count),
        ('Purchases', p.purchase_count)
) metrics(metric_name, metric_value);

Calling Table-Valued Functions

SQL Server table-valued functions naturally combine with APPLY to pass row values as parameters.

-- SQL Server - Table-valued function
CREATE FUNCTION dbo.GetEmployeeHierarchy(@employee_id INT)
RETURNS TABLE
AS
RETURN
(
    WITH hierarchy AS (
        SELECT employee_id, manager_id, employee_name, 0 as level
        FROM employees
        WHERE employee_id = @employee_id
        UNION ALL
        SELECT e.employee_id, e.manager_id, e.employee_name, h.level + 1
        FROM employees e
        INNER JOIN hierarchy h ON e.manager_id = h.employee_id
    )
    SELECT * FROM hierarchy
);

-- Use with CROSS APPLY
SELECT 
    m.manager_name,
    reports.employee_name,
    reports.level
FROM managers m
CROSS APPLY dbo.GetEmployeeHierarchy(m.employee_id) reports;

Performance Considerations

Lateral joins perform best when:

  1. The applied subquery has selective filters based on outer row values
  2. Appropriate indexes exist on join columns and filter predicates
  3. The applied subquery uses LIMIT/TOP to restrict results

Monitor execution plans for:

-- PostgreSQL - Check if indexes are used
EXPLAIN ANALYZE
SELECT d.department_name, e.*
FROM departments d
CROSS JOIN LATERAL (
    SELECT employee_name, salary
    FROM employees
    WHERE department_id = d.department_id
    ORDER BY salary DESC
    LIMIT 5
) e;

Look for “Index Scan” or “Index Seek” operations rather than full table scans. If you see sequential scans, add indexes:

CREATE INDEX idx_employees_dept_salary 
ON employees(department_id, salary DESC);

For SQL Server, examine the actual execution plan for “Nested Loops” with “Index Seek” on the inner side. High logical reads on the applied table indicate missing indexes or non-selective predicates.

Lateral joins and APPLY operators transform complex row-by-row logic into set-based operations. Use them to eliminate scalar subqueries, simplify top-N queries, and enable correlated calculations that would otherwise require procedural code.

Liked this? There's more.

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