Anti-joins — finding what's missing
Users who never ordered. Products that never sold. The three idioms for 'rows in A with no match in B' — and the NOT IN trap that bites everyone.
What you'll learn
- The LEFT JOIN … IS NULL idiom — the classic
- NOT EXISTS — often clearer, and NULL-safe
- Why NOT IN with a NULL returns zero rows, and how to avoid it
- Which idiom to default to
Before you start
Sometimes the answer you want is the absence of something. “Users who never ordered.” “Products that never sold.” “Accounts that didn’t log in last week.” That is an anti-join — the opposite of an inner join’s “only matches” rule: rows in A that have no counterpart in B.
The shaded crescent is the anti-join: rows in A with no counterpart in B.
In our little schema, Asha and Carlos have orders but Bo does not, so “users who never ordered” should return exactly Bo. SQL gives three ways to ask it — and one of them has a famous trap.
Idiom 1 — LEFT JOIN + IS NULL
Keep every user with a left join, then keep only the ones whose right side came back NULL — those are precisely the users with no order:
SELECT u.id, u.name, u.country
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
| id | name | country |
|---|---|---|
| 2 | Bo | CN |
Idiom 2 — NOT EXISTS (the one to default to)
NOT EXISTS (subquery) is true when the subquery finds nothing. It reads as the English question almost word for word, and it is NULL-safe:
SELECT u.id, u.name, u.country
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
| id | name | country |
|---|---|---|
| 2 | Bo | CN |
The subquery only checks “is there at least one?” — it never has to fetch the matching rows, so it can stop at the first one. Same answer, clean intent.
Idiom 3 — NOT IN, and its trap
NOT IN is the most literal translation, and it works fine as long as the inner list has no NULLs: WHERE id NOT IN (SELECT user_id FROM orders) also returns Bo. But let a single NULL into that inner list and it breaks completely — returning zero rows, even though plenty of users obviously are not in the orders list.
Practice
Quick check
Practice this in an interview
All questionsAn anti-join returns rows from the left table that have no match in the right table. NOT IN and NOT EXISTS both express this, but NOT IN returns zero rows — not an error, just silently empty — when the subquery contains even a single NULL, because SQL cannot determine whether the outer value equals a NULL.
An anti-join returns rows from the left table that have no matching row in the right table — the inverse of a semi-join. The three implementations are NOT EXISTS, NOT IN, and a LEFT JOIN with a NULL filter; NOT EXISTS is the most reliable because it is NULL-safe and communicates intent clearly.
EXISTS short-circuits as soon as one match is found and is NULL-safe; IN loads the full subquery result and returns no rows when the list contains a NULL; a JOIN can multiply rows if the right side has duplicates. For large datasets, EXISTS or a deduplicated JOIN is generally safest.
SQL uses three-valued logic: comparing any value to NULL yields UNKNOWN, not FALSE. NOT IN evaluates as NOT (a = v1 OR a = v2 OR ...), so a single NULL in the list makes the entire predicate UNKNOWN for every row, suppressing all results. Use NOT EXISTS or a LEFT JOIN anti-pattern instead.