datarekha

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.

5 min read Intermediate SQL Lesson 9 of 27

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.

ABA, no match 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;
idnamecountry
2BoCN

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);
idnamecountry
2BoCN

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

0/3
Q1SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM orders) — when is this unsafe?
Q2Which two idioms are typically equivalent for an anti-join?
Q3You need 'products never ordered.' Safest choice for production?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is an anti-join, and when does NOT IN behave differently from NOT EXISTS?

An 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.

What is an anti-join, how do you implement one in SQL, and which implementation is most reliable?

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.

When should you use EXISTS, IN, or a JOIN for a semi-join, and what are the NULL-safety differences?

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.

Why does NOT IN (subquery) return zero rows when the subquery contains a NULL, and how do you fix it?

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.

Related lessons

Explore further

Skip to content