Anti-Join: Beginner
"Users Who Never..." Is Always an Anti-Join
Spot anti-join patterns: users who never purchased, products never viewed, events with no match.
Here is the question you will see in your first SQL interview that involves two tables: 'find customers who have never placed an order.' Two minutes later the interviewer has a sense of how you parse the prompt. The candidate who reads the word 'never' and immediately says 'this is an anti-join, I'll use NOT EXISTS' has cleared the first hurdle. The candidate who writes a LEFT JOIN and then has to figure out where to put the IS NULL filter is mid-stream when the interviewer asks the next question. Naming the shape before writing SQL is the move that pays off for the rest of the interview.
- ▸"users who have never X"
- ▸"products that have never been Y"
- ▸"customers without a Z"
- ▸"records present in A but not in B"
- ▸"events with no matching ..."
- ▸Any question framed as the absence of a relationship
What 'anti-join' actually means
Spotting it in the first ten seconds
- ▸"This is an anti-join."
- ▸"I'll use NOT EXISTS, with the subquery checking that no matching row exists."
- ▸"The form is NULL-safe and reads close to the question."
- Writes a LEFT JOIN immediately, then fumbles for the IS NULL filter
- Tries NOT IN without thinking about NULL handling
- Cannot articulate why one form is preferred
- Loses time recovering from a misread of the question
- Says "this is an anti-join, I'll use NOT EXISTS" before writing SQL
- Writes the canonical NOT EXISTS query in six lines
- Names the NULL safety of NOT EXISTS as the reason for the default
- Has the LEFT JOIN WHERE NULL form ready as a fallback if the interviewer asks
Why companies care
LEFT JOIN ... WHERE Right Side IS NULL
Write all three anti-join forms and know which the interviewer expects based on dialect and clarity.
- join the tables; keep rows where the right side is NULL. Explicit and easy to trace, but verbose.
- subquery returns no row → keep the outer row. Reads like the English; NULL-safe by construction. Default reach.
- exclude IDs that appear in the subquery. Reads like English, but ships zero rows when the subquery has a NULL.
Form 1: LEFT JOIN WHERE NULL
Form 2: NOT EXISTS
Form 3: NOT IN
All three produce the same answer on clean data
Reading the NULL Row as "No Match Found"
Explain why NOT IN returns zero rows when the subquery contains a NULL, and how NOT EXISTS avoids this.
The bug, demonstrated
Walk through one customer. Carol has customer_id = 5 and has never ordered. The subquery returns the customer_ids that appear in orders: (1, 2, 3, NULL). NOT IN expands into a chain of inequalities: 5 != 1 AND 5 != 2 AND 5 != 3 AND 5 != NULL. The first three are TRUE. The last one is UNKNOWN, because comparing anything to NULL is UNKNOWN in SQL. TRUE AND TRUE AND TRUE AND UNKNOWN is UNKNOWN. UNKNOWN is not TRUE, so Carol is excluded. Every customer in the table gets excluded by the same logic. Result: empty.
Why this is the language's behavior, not a bug
- ▸x NOT IN (1, 2, NULL) expands to x != 1 AND x != 2 AND x != NULL
- ▸x != NULL is UNKNOWN, not TRUE or FALSE
- ▸TRUE AND TRUE AND UNKNOWN is UNKNOWN
- ▸UNKNOWN is not TRUE, so every row is excluded
- ▸Result: empty. No error, no warning, just zero rows.
Why NOT EXISTS does not hit the trap
- A single NULL anywhere in the subquery returns zero rows total
- The bug is silent: no error, no warning, no log line
- Reproduces only on the data that has the NULL
- Classic interview trap; classic production incident
- NULL rows are evaluated per outer row, not as a poison value
- Correctly excludes only customers who have a matching order
- Behavior is predictable on every data shape
- The reason this should be your default for anti-joins
If you must use NOT IN for some reason (legacy code, dialect restriction), filter the subquery to exclude NULLs explicitly: 'NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL).' The filter restores correctness but adds a fragile dependency: every future author of this code has to remember to add the same filter. NOT EXISTS removes the human-error surface entirely.
The one-sentence rule
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see 'customers who never X' | "I'll use NOT IN." | "This is an anti-join. I default to NOT EXISTS because it's NULL-safe by construction and reads close to the English question." |
| The interviewer asks 'why not NOT IN' | "It's slower." | "Three-valued logic. A single NULL in the subquery makes the chain x != ... AND x != NULL evaluate to UNKNOWN, which is not TRUE, so every row is excluded. The bug is silent: empty result, no error." |
| The interviewer asks 'why not LEFT JOIN WHERE NULL' | "It's verbose." | "Both work and plan the same on modern engines. NOT EXISTS reads closer to the question; LEFT JOIN wins when I need columns from the right side or when the team standardizes on join syntax." |
| You realize the question needs columns from orders | "I'll switch forms." | "NOT EXISTS hides the right table from the outer SELECT. For 'customers and their lifetime-canceled-order count, including customers with zero,' I'd switch to LEFT JOIN with COALESCE(SUM(o.amount), 0) so the right-side columns are in scope." |
| The interviewer asks 'how does it scale' | "It's fast." | "On modern engines all three forms compile to a hash anti-join. The lever is the index on the right side's join column; without it, the anti-join scans the full orders table per customer." |
Anti-Join vs Plain Filter: What's the Difference
Handle composite key anti-joins (e.g., users who visited page A but not page B on the same day).
Case 1: you need columns from the right side
- ▸You need columns from the right side in the SELECT
- ▸The team has a house-style convention for join syntax
- ▸The query is going to be extended with matching rows later
- ▸Otherwise: stick with NOT EXISTS as the default
Case 2: team house style
- The question is purely about absence (no columns from the right side)
- The team has no established convention
- The subquery condition might involve multiple columns
- Defaulting to NULL safety matters more than syntactic preference
- The query needs columns from the right side (totals, latest values)
- The team's house style favors join syntax
- The query is going to be extended later to include matching rows
- An optimizer's plan for LEFT JOIN is easier for the team to reason about
The mistake to avoid
The rule is: conditions on the left side go in WHERE; conditions on the right side go in ON. Mixing them up is the most common LEFT-JOIN-WHERE-NULL bug. When in doubt, ask: 'should this condition affect whether the row matches, or whether the matched row is included?' Match conditions go in ON; result filters go in WHERE.
Drawing the Two Sets Before You Write SQL
Discuss query plan differences between the three forms and when the optimizer treats them identically.
The supporting index
Why all three forms have the same plan on modern engines
When the plan is bad
The closing summary
> You are in an Oracle data engineering phone screen. The interviewer asks: 'Find customers who have never placed an order.'
NOT EXISTS. It is NULL safe by construction, reads close to the English of the prompt, and the planner gives it the same hash anti-join plan as the LEFT JOIN form.NOT IN returns zero rows, not wrong rows, the moment a single NULL lands in the subquery, because value != NULL is UNKNOWN and never TRUE. This is the production bug the question exists to test.LEFT JOIN with WHERE right.key IS NULL for two reasons only: you need columns from the right side in the output, or the team's house style calls for it.LEFT JOIN form, conditions on the right table belong in ON, not WHERE. A right-side predicate in WHERE kills the NULL-padded rows and silently turns the anti-join into an inner join.EXPLAIN confirms you got a hash anti-join instead of a nested loop.Finding what is NOT there is harder than finding what is
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: "Users Who Never..." Is Always an Anti-Join, LEFT JOIN ... WHERE Right Side IS NULL, Reading the NULL Row as "No Match Found", Anti-Join vs Plain Filter: What's the Difference, Drawing the Two Sets Before You Write SQL
Lesson Sections
- "Users Who Never..." Is Always an Anti-Join (concepts: sqlExists)
What 'anti-join' actually means A regular join (INNER JOIN) returns rows where the two tables match on the join condition. An anti-join returns rows from the left side that have no matching row in the right side. The output has the same columns as the left table; the right table is consulted only to decide which rows to keep. Customers who never placed an order means: every customer row whose customer_id does not appear in the orders table. The shape of the output is 'left rows where nothing mat
- LEFT JOIN ... WHERE Right Side IS NULL (concepts: sqlNullInJoins)
The same English question maps to three SQL forms. All three return the same rows on clean data. You should be able to write each of them in six lines without thinking. Knowing all three is the floor. Knowing which one to default to is what the interviewer is reading you for. Form 1: LEFT JOIN WHERE NULL Join the two tables with a LEFT JOIN. The LEFT JOIN keeps every row from the left side, attaching matching rows from the right side where they exist and NULLs where they do not. Filter the resul
- Reading the NULL Row as "No Match Found" (concepts: sqlNullInJoins)
This is the section the interview question exists to test. NOT IN with a NULL in the subquery returns zero rows. Not 'some' rows. Not 'wrong' rows. Zero. The dashboard reads zero. The on-call gets paged. The candidate who has not seen this learns it the hard way. The candidate who has seen it defaults to NOT EXISTS and avoids the trap entirely. The bug, demonstrated Why this is the language's behavior, not a bug SQL has three truth values: TRUE, FALSE, and UNKNOWN. Comparing any value to NULL re
- Anti-Join vs Plain Filter: What's the Difference (concepts: sqlExists)
NOT EXISTS is the default. LEFT JOIN WHERE NULL is the fallback for two specific cases. Knowing both forms cold means you can switch when the situation calls for it without rewriting from scratch. The interviewer at this level may ask you to write all three; being smooth between them is the signal. Case 1: you need columns from the right side NOT EXISTS hides the right table from the outer SELECT. The subquery is a yes-or-no check; you cannot return o.amount or o.order_date from the outer query
- Drawing the Two Sets Before You Write SQL (concepts: sqlExists)
Past correctness, the interviewer will ask about performance. The answer does not need to be deep at this stage, but it needs to name two things: the supporting index on the right side, and the choice between checking the plan versus rewriting the query. Two beats are enough here; the deeper conversation about hash anti-joins and shuffles belongs in the advanced lesson. The supporting index Anti-joins on a large table are fast when the join column on the right side has an index. NOT EXISTS, LEFT