Semi-Join and Anti-Join Plan Shapes #
A cleanup query — find accounts with no orders — has run for twenty minutes, and its plan shows a single Seq Scan on accounts with a Filter: (NOT (hashed SubPlan 1)), or worse, Filter: (NOT (SubPlan 1)) with the subquery executed once per account. The same logic written with NOT EXISTS finishes in four seconds as a Hash Anti Join. The difference is not style; it is which queries the planner is allowed to turn into joins.
Semi- and anti-joins are part of the join list described in join order and collapse limits, which means they can be reordered and given any join algorithm — but only after the planner has converted the subquery. This page covers when that conversion happens.
The Planner Condition #
Semi-join — “return the outer row once if at least one inner row matches.” Produced from:
WHERE EXISTS (SELECT 1 FROM t WHERE t.k = o.k)WHERE o.k IN (SELECT k FROM t)WHERE o.k = ANY (SELECT k FROM t)
Anti-join — “return the outer row if no inner row matches.” Produced from:
WHERE NOT EXISTS (SELECT 1 FROM t WHERE t.k = o.k)LEFT JOIN t ON t.k = o.k WHERE t.k IS NULL(whent.kis a column that cannot otherwise be null)
Not converted — kept as a SubPlan filter:
WHERE o.k NOT IN (SELECT k FROM t). Ift.kcan produce aNULL,o.k NOT IN (…, NULL)is never true, so no row is returned at all. An anti-join would return rows. The planner does not perform this rewrite, and you should not expect it to even when the column is declaredNOT NULL.- Subqueries under
OR, or correlated in ways that cannot be expressed as a join qualification.
A SubPlan is either hashed — the subquery runs once and its results go into an in-memory hash table sized against work_mem — or plain, re-executed for every outer row. The planner uses a hashed SubPlan only when it expects the result to fit in work_mem; otherwise it falls back to per-row execution, which is where the twenty-minute runtimes come from.
Annotated EXPLAIN Evidence #
The unconverted form:
EXPLAIN (ANALYZE, BUFFERS)
SELECT a.id FROM accounts a
WHERE a.id NOT IN (SELECT o.account_id FROM orders o);
Seq Scan on accounts a (actual time=1204210.6..1204388.1 rows=18220 loops=1)
Filter: (NOT (SubPlan 1)) -- not hashed: re-run per account
Rows Removed by Filter: 2381780
SubPlan 1
-> Materialize (actual time=0.01..0.49 rows=… loops=2400000)
-> Seq Scan on orders o (actual rows=94022110 loops=1)
Execution Time: 1204392.8 ms
-- Materialize loops=2400000 → the stored orders list was searched once per account row
-- the planner declined a hashed SubPlan because 94M account_ids would not fit in work_mem
The converted form:
EXPLAIN (ANALYZE, BUFFERS)
SELECT a.id FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.account_id = a.id);
Hash Right Anti Join (actual time=3310.2..4102.7 rows=18220 loops=1)
Hash Cond: (o.account_id = a.id)
-> Seq Scan on orders o (actual rows=94022110 loops=1)
-> Hash (actual rows=2400000 loops=1)
Buckets: 4194304 Batches: 1 Memory Usage: 117188kB
-> Seq Scan on accounts a (actual rows=2400000 loops=1)
Execution Time: 4118.4 ms
-- "Right" → the hash is built on accounts (the preserved side), orders probes it
-- unmatched hash entries are emitted at the end
With an index on orders(account_id) and a small outer set, the planner may pick Nested Loop Anti Join with an Index Only Scan on the inner side instead — the same logical operation with different physical costs, described in merge join vs nested loop.
Step-by-Step Resolution #
-
Find
SubPlanin aFilter. Search plan text forSubPlanorhashed SubPlan. A non-hashed SubPlan withloops=equal to the outer row count is the urgent case. -
Check nullability of the subquery column.
SELECT attnotnull FROM pg_attribute WHERE attrelid = 'orders'::regclass AND attname = 'account_id'; SELECT count(*) FROM orders WHERE account_id IS NULL; -
Rewrite
NOT INasNOT EXISTS. If the column is non-nullable, the results are identical. If it is nullable, decide which semantics you actually wanted — almost always theNOT EXISTSbehaviour, where null order rows simply do not match any account.SELECT a.id FROM accounts a WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.account_id = a.id); -
Confirm the join shape. Look for
Anti Joinin the node name and the join condition inHash Cond,Merge Condor the innerIndex Cond. -
Make the inner side cheap. For a nested-loop anti-join, an index on the correlated column makes each probe an index-only lookup. For a hash anti-join, check
Batches:on theHashnode; spills behave exactly like the hash join batch spills of an inner join.
Before and After #
-- BEFORE: NOT IN, non-hashed SubPlan
Seq Scan on accounts Filter: (NOT (SubPlan 1)) Materialize loops=2400000 Execution Time: 1204392.8 ms
-- AFTER: NOT EXISTS
Hash Right Anti Join Hash Cond: (o.account_id = a.id) Batches: 1 Execution Time: 4118.4 ms
Common Pitfalls #
Changing results by rewriting a nullable NOT IN. If the subquery really returns nulls, NOT IN returns zero rows and NOT EXISTS returns some. Diagnostic signal: the “fixed” query returns rows that the original never did. Fix: decide the intended semantics explicitly; add WHERE o.account_id IS NOT NULL inside the original if you must preserve NOT IN meaning while enabling analysis.
The LEFT JOIN … IS NULL form on a nullable column. The anti-join rewrite requires that the tested column is null only because no match occurred. Diagnostic signal: a Hash Left Join with Filter: (o.account_id IS NULL) instead of an anti-join. Fix: test a non-nullable column of the inner table, such as its primary key, or use NOT EXISTS.
Hashed SubPlans that stop being hashed as data grows. A hashed SubPlan is chosen only while the planner expects it to fit in work_mem. Diagnostic signal: a query that slows by orders of magnitude after a table crosses a size, with hashed disappearing from the plan. Fix: rewrite to a join form, which can spill to disk gracefully instead of falling back to per-row execution.
Semi-join estimates that drive a bad order. Semi-join selectivity depends on distinct counts of the join key, so an underestimated n_distinct distorts it. Diagnostic signal: a Semi Join whose rows= is far below actual rows. Fix: check n_distinct and consider an override.
Frequently Asked Questions #
Why is NOT IN slower than NOT EXISTS in PostgreSQL?
NOT IN behaves differently when the subquery can return NULL, so the planner cannot rewrite it into an anti-join. It runs as a SubPlan, hashed when the result fits in memory and re-executed per row when it does not.
What does Hash Right Anti Join mean? The hash table is built on the preserved outer side and the other side probes it, emitting unmatched entries at the end. PostgreSQL 16 added it so the smaller side can be hashed; PostgreSQL 18 added the semi-join equivalent.
Are IN and EXISTS planned the same way?
Mostly. Both become semi-joins. IN (subquery) can additionally be planned by making the subquery’s output unique and running an inner join, visible as a HashAggregate or Unique under the join.
Related #
- Join Order and Collapse Limits — parent guide: how semi- and anti-joins join the ordering search
- Subquery Flattening and Pull-Up Rules — the pull-up pass that performs these conversions
- Diagnosing Hash Join Batch Spills — reading the Hash node under an anti-join