Which of NOT EXISTS, NOT IN and LEFT JOIN ... IS NULL correctly returns customers with no orders?
answer
- Three spellings of the same idea
- One of them can silently return nothing
- Test the join key, not a payload column
- NULL-extension marks the unmatched rows
- NOT EXISTS never yields UNKNOWN
basics
~20 sAll three can express the anti-join, but only NOT EXISTS is unconditionally safe. LEFT JOIN ... WHERE o.customer_id IS NULL is equivalent when the IS NULL test uses the join key and every orders predicate sits in ON. NOT IN breaks if the subquery yields any NULL.
solid answer
~50 sAll three are anti-join spellings — "left rows with no matching right row" — and they agree only under conditions worth naming. `NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)` is the reference form: always correct, extends to several correlated predicates, and never surprises you with NULLs. `LEFT JOIN orders o ON o.customer_id = c.id WHERE o.customer_id IS NULL` is equivalent provided two things hold: the `IS NULL` test names a column that cannot be NULL in a genuinely matched row — the join key is the safe choice — and any additional filter on `orders` lives in `ON`, not `WHERE`, or the join collapses to an inner join. It is duplicate-free by construction, because the rows that survive the filter are exactly the NULL-extended ones, and NULL-extension produces one row per unmatched left row. `NOT IN (SELECT o.customer_id FROM orders o)` is the fragile one: a single NULL in that subquery makes the predicate never TRUE, so the query returns nothing.
code
sql · 12 lines-- Reference form: always correct
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- Equivalent when the IS NULL test names the join key
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.customer_id IS NULL;go deeper
Be able to write at least the NOT EXISTS form from memory and explain in one sentence what an anti-join returns. Recognising that LEFT JOIN plus IS NULL means the same thing is a strong bonus at this level.
Explain the mechanics of each: NULL-extension for the outer-join form, per-row negation for NOT EXISTS, and why NOT IN over a nullable subquery collapses. Naming the equivalence conditions is what is actually being graded here.
Show the diagnostic instinct: a report that suddenly returns zero rows points at NOT IN plus a newly nullable column, and a report that returns everything points at a filter moved from ON to WHERE. State a default and justify it.
Own the convention. Decide whether the codebase standardises on NOT EXISTS, and back it with NOT NULL constraints where the domain allows, so the fragile form cannot silently empty a result set during a schema change.
## The pattern "Rows on the left with no match on the right" is the **anti-join**. It is the complement of the semi-join, it appears constantly (customers who never ordered, products never sold, staging rows not yet loaded), and SQL gives you three idiomatic spellings. An interviewer asking for all three is checking whether you understand them as one concept with different failure modes, rather than as three memorised snippets. ## Form 1 — NOT EXISTS (the reference implementation) ```sql SELECT c.id, c.name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id ); ``` `EXISTS` yields TRUE or FALSE and never UNKNOWN, so `NOT EXISTS` is simply its negation: keep the customer when the correlated subquery would produce no rows. Nothing about NULLs in `orders.customer_id` can change that — a NULL customer_id never satisfies `o.customer_id = c.id`, so such a row is not a match, which is exactly the answer you want. The pattern also generalises without thought: matching on two columns just means two ANDed predicates inside the subquery. ## Form 2 — LEFT JOIN with an IS NULL test ```sql SELECT c.id, c.name FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.customer_id IS NULL; ``` The left join keeps every customer; unmatched customers get a row whose `orders` columns are all NULL (NULL-extension). The `WHERE` then keeps only those NULL-extended rows. Two details make or break it. **Which column you test.** Pick a column that cannot be NULL in a real matched row. The join key is always safe: if `o.customer_id` is NULL in the output, the only possible cause is NULL-extension, because a row that matched must have had `o.customer_id = c.id`. Testing a nullable payload column such as `o.cancelled_at` is a genuine bug — customers with a real, uncancelled order would be reported as having no orders at all. **Where the extra predicates go.** If the requirement is "customers with no order in 2024", the year filter belongs in `ON`, not `WHERE`. Put it in `WHERE` and the NULL-extended rows fail it, turning the query into an inner join and returning nothing. (Predicate placement with outer joins is a topic in its own right; the anti-join is simply its most common victim.) A nice property: this form cannot produce duplicates. Multiplicity in an outer join comes from *matching* rows, and the surviving rows are by definition the non-matching ones — exactly one per unmatched left row. ## Form 3 — NOT IN (the fragile one) ```sql SELECT c.id, c.name FROM customers c WHERE c.id NOT IN (SELECT o.customer_id FROM orders o); ``` When `orders.customer_id` is declared `NOT NULL`, this is equivalent to the other two and reads very naturally. When it is nullable and even one row holds NULL, the predicate can never be TRUE for any customer, and the query returns the empty result set — silently, with no error. If you must use this form over a nullable column, add `WHERE o.customer_id IS NOT NULL` inside the subquery, and be aware that the three-valued-logic reasoning behind the trap is a topic worth studying on its own. One more asymmetry that catches people out in the opposite direction: if the subquery returns **no rows at all**, `NOT IN` is TRUE for every customer, so an empty `orders` table returns all customers. That is correct, but it means an empty subquery and a NULL-containing subquery produce opposite extremes. ## Choosing between them Default to `NOT EXISTS`. It states the intent ("no such row exists"), is immune to the NULL trap, needs no thought about which column to test, and extends to composite keys. Reach for `LEFT JOIN ... IS NULL` when the surrounding query already left-joins that table for other reasons, or when a team's house style prefers it — just discipline yourself to test the join key. Treat `NOT IN` over a subquery as a smell unless the column is provably `NOT NULL`; over a literal list (`status NOT IN ('a','b')`) it is perfectly ordinary. ## What to say in the interview Name the concept ("these are all anti-joins"), give `NOT EXISTS` as the safe default, then state the two equivalence conditions for the left-join form and the one failure mode of `NOT IN`. That sequence shows you know they are the same idea, and that you know exactly where the idea stops holding.
- Why can the LEFT JOIN form never produce duplicate rows for an unmatched customer?Because duplicates in an outer join come from matching rows — one output row per match. The WHERE ... IS NULL filter keeps only NULL-extended rows, and an unmatched left row is NULL-extended exactly once by definition. So the surviving set is one row per unmatched customer, the same cardinality NOT EXISTS gives.
- How does the requirement change if you want customers with no orders in 2024 rather than no orders at all?With NOT EXISTS, add the date predicate inside the subquery alongside the correlation. With the LEFT JOIN form, the date predicate must go in the ON clause: in WHERE it would reject the NULL-extended rows and silently turn the anti-join into an inner join, returning nothing.
- Is NOT IN always wrong?No. Over a literal list, or over a column declared NOT NULL, it is exact and readable. The hazard is a subquery over a nullable column, where one NULL makes the predicate never TRUE and the query returns nothing at all. If you keep the form, filter NULLs out inside the subquery explicitly.
saying these in an interview costs you the question
- Says the three forms are always interchangeable
- Tests IS NULL on an arbitrary nullable column of the right table
- Believes NOT IN over NULLs raises an error rather than returning nothing
- Puts the extra orders filter in WHERE and keeps calling it a LEFT JOIN
- Thinks the LEFT JOIN anti-join can return duplicates