Join Removal for Unused LEFT JOINs #
A reporting view joins orders to eight lookup tables with LEFT JOIN so every consumer can pick the columns it needs. A dashboard selects only three columns from orders through the view. One copy of the database plans it as a single index scan; another, identical except for a missing unique constraint on one lookup table, joins all eight tables for every row. The difference is join removal.
The planner can only reorder what remains in the query, as described in join order and collapse limits. Join removal happens earlier: it deletes joins that cannot affect the result.
The Planner Condition #
A LEFT JOIN to relation B can be removed when both of these hold:
- No column of
Bis used anywhere above the join — not in theSELECTlist,WHERE,GROUP BY,ORDER BY, or other join conditions. The join condition itself does not count. - The join cannot duplicate rows of the left side. The planner must prove each left row matches at most one row of
B. It does this with a unique index or constraint onB’s join columns (primary key,UNIQUE, or a unique index whose columns are all equated by the join condition), or withBbeing a subquery that is unique byDISTINCTorGROUP BYon those columns.
Under those conditions the left join returns exactly one row per left row, with B’s columns unused, so dropping it cannot change the result.
What is not removed:
- Inner joins, even to a unique key backed by a foreign key. An inner join also filters — a missing match removes the left row — and PostgreSQL does not use foreign key constraints to prove matches exist.
- Left joins on non-unique keys, which may multiply rows.
- Left joins whose columns are referenced, even trivially, such as
SELECT *through a view. - Joins to relations whose uniqueness comes only from application logic.
Removed joins leave no trace in EXPLAIN: the relation simply does not appear.
Annotated EXPLAIN Evidence #
CREATE VIEW order_report AS
SELECT o.*, c.name AS customer_name, s.label AS status_label, cur.symbol AS currency_symbol
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
LEFT JOIN statuses s ON s.code = o.status_code
LEFT JOIN currencies cur ON cur.iso = o.currency_iso;
EXPLAIN (ANALYZE)
SELECT id, total, placed_at FROM order_report WHERE placed_at >= now() - interval '1 day';
When statuses.code has no unique constraint:
Hash Left Join (actual time=0.9..612.4 rows=184002 loops=1)
Hash Cond: (o.status_code = s.code)
-> Index Scan using orders_placed_at_idx on orders o (actual rows=184002 loops=1)
-> Hash (actual rows=14 loops=1)
-> Seq Scan on statuses s (actual rows=14 loops=1)
Execution Time: 640.2 ms
-- customers and currencies were removed (unique keys, unused columns)
-- statuses stays: code is not provably unique, so the join might duplicate orders
After adding the constraint:
ALTER TABLE statuses ADD CONSTRAINT statuses_code_key UNIQUE (code);
Index Scan using orders_placed_at_idx on orders o (actual time=0.02..402.1 rows=184002 loops=1)
Index Cond: (placed_at >= (now() - '1 day'::interval))
Execution Time: 418.6 ms
-- all three lookup joins removed: no join nodes at all
Step-by-Step Resolution #
-
Compare the relations in the plan with those in the query or view. Relations missing from the plan were removed; those present with unused columns were not.
-
For each retained
LEFT JOINwith unused columns, check uniqueness of the join key:SELECT i.indexrelid::regclass, i.indisunique, i.indkey FROM pg_index i WHERE i.indrelid = 'statuses'::regclass; -
Add the missing unique constraint where the data really is unique. Build the index concurrently first on large tables, then attach it:
CREATE UNIQUE INDEX CONCURRENTLY statuses_code_uidx ON statuses (code); ALTER TABLE statuses ADD CONSTRAINT statuses_code_key UNIQUE USING INDEX statuses_code_uidx; -
Stop referencing columns you do not need.
SELECT *through a view references every column and prevents all removal; select explicit columns. -
Keep optional lookups as
LEFT JOINin views. Converting them to inner joins makes them non-removable and also filters rows without matches. -
Verify with
EXPLAINfor the narrow query shapes consumers actually run.
Before and After #
-- BEFORE: statuses.code without a unique constraint
Hash Left Join (statuses) → Index Scan on orders Execution Time: 640.2 ms
-- AFTER: UNIQUE (code) on statuses
Index Scan using orders_placed_at_idx on orders Execution Time: 418.6 ms
Designing views that stay cheap #
Join removal is what makes wide “everything” views practical. A view can offer dozens of optional lookup columns while each consumer pays only for the joins it uses — provided every optional relation is left-joined on a unique key. That design rule is worth enforcing in schema reviews: every lookup table referenced by a reporting view should have a primary key or unique constraint on the join column, and the view should use LEFT JOIN for anything a consumer might not need.
Two limits apply. First, removal is decided per query, so an ORM that always selects every column of a model mapped onto such a view defeats it; map narrower models or use explicit column lists for hot paths. Second, removal only considers the join’s uniqueness, not whether the joined table is empty or small, so a cheap-looking join to a tiny table is still worth removing in a hot query that runs thousands of times per second. Joins retained because a filter references the lookup table are candidates for outer join reduction instead.
Common Pitfalls #
Assuming foreign keys enable removal. PostgreSQL does not remove inner joins using foreign keys. Diagnostic signal: an inner join with unused columns still in the plan. Fix: left join on a unique key if the lookup is optional, or drop the join from the query.
SELECT * through views. Every column is referenced. Diagnostic signal: all joins retained for a narrow consumer. Fix: explicit column lists.
Uniqueness enforced only by the application. The planner cannot trust it. Diagnostic signal: retained joins on natural keys. Fix: add a unique constraint.
Partial unique indexes. A unique index with a WHERE clause does not prove uniqueness for all rows. Diagnostic signal: join retained despite a unique partial index. Fix: a full unique constraint where valid.
Frequently Asked Questions #
When does PostgreSQL remove a LEFT JOIN? #
When no column of the joined table is used by the query and the join key is provably unique on that table, usually through a primary key or unique constraint. The join then cannot change the result and is dropped from the plan.
Does PostgreSQL remove inner joins on foreign keys? #
No. Inner joins also filter out rows without a match, and PostgreSQL does not use foreign key constraints to prove every row has one, so inner joins stay in the plan even when their columns are unused.
How can I tell whether a join was removed? #
Removed relations simply do not appear in EXPLAIN output. Compare the relations listed in the plan with those in the query or view definition.
Related #
- Join Order and Collapse Limits — parent guide: what remains after removal is searched
- Unique Indexes and Planner Uniqueness Proofs — the uniqueness information removal relies on
- Outer Join Reordering Rules — sibling: what happens to joins that stay