SQL Anti-Join: NOT EXISTS vs LEFT JOIN vs NOT IN
"Find rows with no match" has three common SQL implementations, and they aren't equally safe. Here's all three side by side, and why NOT EXISTS should usually be your default.
Finding rows that have no match
An anti-join returns rows from one table that have no matching row in another — "departments with zero employees", "customers who never placed an order". SQL has no dedicated ANTI JOIN keyword; instead, there are three common ways to express the same idea, and they don't all behave identically.
Method 1: NOT EXISTS (usually the best default)
A correlated subquery checking for absence.
SELECT d.department_name
FROM departments d
WHERE NOT EXISTS (
SELECT 1 FROM employees e WHERE e.department_id = d.department_id
);
Method 2: LEFT JOIN + IS NULL
Join, then keep only the rows where nothing matched.
SELECT d.department_name
FROM departments d
LEFT JOIN employees e ON e.department_id = d.department_id
WHERE e.employee_id IS NULL;
Method 3: NOT IN (the risky one)
Works, but only if the subquery's column can never be NULL.
SELECT d.department_name
FROM departments d
WHERE d.department_id NOT IN (
SELECT department_id FROM employees WHERE department_id IS NOT NULL -- note the guard
);
Which to use
| Method | Correctness | Notes |
|---|---|---|
| NOT EXISTS | always correct, NULL-safe | generally the clearest and safest default |
| LEFT JOIN + IS NULL | correct | familiar if you already think in joins; can return duplicate rows if the join itself is one-to-many |
| NOT IN | correct ONLY if the subquery column is guaranteed non-NULL | silently returns zero rows for the whole query if even one NULL sneaks into the subquery — see the IN vs EXISTS article |
The practical recommendation
Default to NOT EXISTS. It's NULL-safe by construction (no guard clause needed), reads clearly as "rows where this condition doesn't exist", and modern optimizers handle it efficiently — including on large tables, where it can stop checking a given outer row as soon as one match is found. Reach for NOT IN only when you've explicitly confirmed the subquery's column can't contain NULL.