SQLMentor // learn postgresql

Joins & LATERAL

Joins combine rows from two or more tables. PostgreSQL supports all standard join types and adds LATERAL, one of its most powerful and distinctive features. This chapter covers standard joins with practical examples, then dives deep into LATERAL.

Setup — Sample Tables

CREATE TABLE employees (
    id         SERIAL PRIMARY KEY,
    name       TEXT,
    dept_id    INTEGER,
    salary     NUMERIC(10,2),
    manager_id INTEGER
);

CREATE TABLE departments (
    id         SERIAL PRIMARY KEY,
    name       TEXT,
    budget     NUMERIC(12,2)
);

CREATE TABLE projects (
    id          SERIAL PRIMARY KEY,
    title       TEXT,
    dept_id     INTEGER,
    start_date  DATE
);

INSERT INTO departments VALUES (1,'Engineering',500000),(2,'Marketing',200000),(3,'HR',150000);
INSERT INTO employees VALUES
    (1,'Alice',1,95000,NULL),(2,'Bob',1,85000,1),(3,'Carol',2,72000,NULL),
    (4,'Dave',2,68000,3),(5,'Eve',NULL,60000,NULL);
INSERT INTO projects VALUES
    (1,'Alpha',1,'2024-01-01'),(2,'Beta',1,'2024-03-01'),
    (3,'Gamma',2,'2024-02-01'),(4,'Delta',3,'2024-04-01');

INNER JOIN

Returns only rows where there is a match in both tables.

SELECT e.name, d.name AS department
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id;
name department
Alice Engineering
Bob Engineering
Carol Marketing
Dave Marketing

Note: Eve (dept_id IS NULL) is excluded because there's no matching department.

LEFT JOIN (LEFT OUTER JOIN)

Returns all rows from the left table, with NULL for non-matching right-table columns.

SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;
name department
Alice Engineering
Bob Engineering
Carol Marketing
Dave Marketing
Eve null

RIGHT JOIN

Returns all rows from the right table, NULLs for non-matching left-table columns.

SELECT e.name, d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.id;
name department
Alice Engineering
Bob Engineering
Carol Marketing
Dave Marketing
null HR

HR department appears even though no employee is assigned to it.

FULL JOIN (FULL OUTER JOIN)

Returns all rows from both tables, with NULLs where there is no match.

SELECT e.name, d.name AS department
FROM employees e
FULL JOIN departments d ON e.dept_id = d.id;
name department
Alice Engineering
Bob Engineering
Carol Marketing
Dave Marketing
Eve null
null HR

CROSS JOIN

Returns the Cartesian product — every row from the first table paired with every row from the second.

-- Generate all size/color combinations
SELECT s.size, c.color
FROM (VALUES ('S'),('M'),('L')) AS s(size)
CROSS JOIN (VALUES ('Red'),('Blue'),('Green')) AS c(color);
size color
S Red
S Blue
S Green
M Red
... ...

Using USING Instead of ON

When the join column has the same name in both tables, USING is cleaner:

-- These are equivalent
SELECT e.name, d.name FROM employees e JOIN departments d ON e.dept_id = d.id;
SELECT e.name, d.name FROM employees e JOIN departments d USING (dept_id);
-- Note: USING produces only one dept_id column in the result (not two)

Self-Join

A table joined to itself — useful for hierarchical relationships:

-- Employee + their manager (both in the same table)
SELECT
    e.name    AS employee,
    m.name    AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.name;
employee manager
Alice null
Bob Alice
Carol null
Dave Carol
Eve null

Anti-Join — Find Non-Matching Rows

-- Employees with no projects (using NOT EXISTS)
SELECT e.name
FROM employees e
WHERE NOT EXISTS (
    SELECT 1 FROM projects p WHERE p.dept_id = e.dept_id
);

-- Alternative with LEFT JOIN + IS NULL
SELECT e.name
FROM employees e
LEFT JOIN projects p ON e.dept_id = p.dept_id
WHERE p.id IS NULL;

LATERAL JOIN — The PostgreSQL Star Feature

LATERAL allows a subquery in the FROM clause to reference columns from preceding tables — making it more like a correlated subquery that produces a table, not just a scalar value.

Without LATERAL, a subquery in FROM cannot reference columns from other tables in the same FROM clause. LATERAL removes this restriction.

Syntax

SELECT ...
FROM table1 t1
JOIN LATERAL (
    SELECT ... FROM table2 WHERE table2.col = t1.col  -- can reference t1!
    LIMIT 5
) AS alias ON TRUE;

Example 1: Top N Records Per Group

Get the top 2 earners per department:

-- Without LATERAL (complex with window functions or correlated subqueries)
-- With LATERAL (elegant):

SELECT d.name AS department, top_earners.name, top_earners.salary
FROM departments d
JOIN LATERAL (
    SELECT e.name, e.salary
    FROM employees e
    WHERE e.dept_id = d.id
    ORDER BY e.salary DESC
    LIMIT 2
) AS top_earners ON TRUE
ORDER BY d.name, top_earners.salary DESC;
department name salary
Engineering Alice 95000
Engineering Bob 85000
Marketing Carol 72000
Marketing Dave 68000

This is the classic "top N per group" pattern. The subquery runs once per department row, each time using the current d.id to filter employees.

Example 2: LEFT JOIN LATERAL for Optional Results

Use LEFT JOIN LATERAL when you want departments even if no employees match:

SELECT d.name AS department, top_emp.name, top_emp.salary
FROM departments d
LEFT JOIN LATERAL (
    SELECT e.name, e.salary
    FROM employees e
    WHERE e.dept_id = d.id
    ORDER BY e.salary DESC
    LIMIT 1
) AS top_emp ON TRUE;
department name salary
Engineering Alice 95000.00
Marketing Carol 72000.00
HR null null

Example 3: LATERAL with Computed Values

LATERAL can also run functions or complex expressions per row:

-- For each employee, calculate their salary percentile across the company
SELECT
    e.name,
    e.salary,
    pct.percentile
FROM employees e
JOIN LATERAL (
    SELECT
        ROUND(
            100.0 * COUNT(*) FILTER (WHERE e2.salary <= e.salary) / COUNT(*),
            1
        ) AS percentile
    FROM employees e2
) AS pct ON TRUE;

Example 4: LATERAL with JSON/Array Functions

LATERAL is commonly used with UNNEST and JSON functions that produce sets of rows:

-- Unnest an array, keeping the parent row context
SELECT order_id, tag
FROM orders_with_tags,
LATERAL UNNEST(tags) AS tag;
-- Equivalent to: SELECT order_id, UNNEST(tags) FROM orders_with_tags;

-- Parse JSON arrays inline
SELECT user_id, item->>'name' AS item_name
FROM user_carts,
LATERAL jsonb_array_elements(cart_items) AS item;
UNNEST(array) and jsonb_array_elements(jsonb) are set-returning functions. When you call them in the SELECT list, PostgreSQL implicitly treats them as LATERAL. Putting them in FROM LATERAL is more explicit and portable.

Join Decision Guide

Which join type should I use?
Goal Use
Only matching rows from both tables INNER JOIN
All left rows, match or not LEFT JOIN
All rows from both, fill NULLs FULL JOIN
All combinations (Cartesian product) CROSS JOIN
Find rows with no match in other table LEFT JOIN ... WHERE right.id IS NULL
Top N rows per group JOIN LATERAL (...LIMIT N) ON TRUE
Run a function per row, get table result JOIN LATERAL
Hierarchy / self-referential Self-join (LEFT JOIN same table)

Performance Tips

-- Always join on indexed columns
-- PostgreSQL can use indexes for JOIN conditions

-- Check your join plan
EXPLAIN ANALYZE
SELECT e.name, d.name
FROM employees e
JOIN departments d ON e.dept_id = d.id;

-- The planner picks from: Nested Loop, Hash Join, Merge Join
-- Hash Join is common for larger tables with no useful index
-- Nested Loop is common when one side is small or indexed
For LATERAL joins that use ORDER BY + LIMIT, make sure there is an index on the ORDER BY column filtered by the join predicate. For the top-earners example, an index on (dept_id, salary DESC) lets PostgreSQL use an Index Scan instead of sorting the full table for each department.