open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 02 · 03

Semi and anti joins

EXISTS/IN return left rows that have a match without fanning out (a semi-join); NOT EXISTS/NOT IN return left rows with no match (an anti-join). NOT IN breaks silently when the subquery yields a NULL — prefer NOT EXISTS.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A NOT IN query that powered a “customers with no failed payments” promotion shipped, looked correct in code review, and returned an empty set in production — so nobody got the offer. The subquery it checked against had exactly one row with a NULL payment_id, buried among millions. That single NULL flipped the entire NOT IN to “no rows,” and three-valued logic did it silently, no error. The senior who debugged it didn’t read the data; they recognized the shape. NOT IN + a nullable subquery column is a known landmine.

Semi-join: “does a match exist?” — yes/no, no fan-out

Often you don’t want the matched rows from the other table; you only want to know whether a match exists. “Users who have placed at least one order.” A plain JOIN answers this badly: if a user has 50 orders, the join emits that user 50 times, and you have to bolt on DISTINCT to undo the damage. That’s a fan-out followed by a cleanup.

A semi-join is the right tool: it returns each qualifying left row exactly once, regardless of how many matches it has on the right. SQL spells it EXISTS or IN:

-- Users who have at least one order — each user once, never duplicated
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- Equivalent semi-join via IN
SELECT u.id, u.name
FROM users u
WHERE u.id IN (SELECT user_id FROM orders);

EXISTS stops scanning the inner side as soon as it finds the first matching row — it’s a short-circuit boolean, not a count. That’s why a semi-join is usually cheaper than JOIN ... DISTINCT: it never builds the duplicated intermediate result just to collapse it again. The planner even shows this as a dedicated Semi Join node in EXPLAIN (the databases track’s join-algorithms lesson covers how it’s executed).

Anti-join: “has no match” — the complement

The mirror of a semi-join is the anti-join: return the left rows that have no match. “Users who have never ordered.” Spelled NOT EXISTS or NOT IN:

-- Users who have never ordered (anti-join via NOT EXISTS)
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

This is the same set as the LEFT JOIN ... WHERE o.id IS NULL pattern from the previous lesson, and the planner often executes them identically (an Anti Join node). NOT EXISTS is the clearest way to express it and is immune to the NULL trap below.

The NOT IN + NULL trap — the reason this lesson exists

NOT IN looks like the natural anti-join, and it works fine — until the subquery returns even one NULL. Then it silently returns zero rows, exactly the production bug from the hook.

-- DANGEROUS: if any order has a NULL user_id, this returns NOTHING
SELECT u.id FROM users u
WHERE u.id NOT IN (SELECT user_id FROM orders);

The cause is three-valued logic. x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL. The last comparison x <> NULL is UNKNOWN, never true, so the whole AND can never be true — at best it’s UNKNOWN, which WHERE discards. One NULL in the list makes the predicate un-satisfiable for every row. There’s no error; the result set just collapses.

NOT EXISTS does not have this flaw: it asks “is there a correlated matching row?” per left row, and a NULL on the right simply fails to match, which is the correct anti-join behavior. Production rule: never write NOT IN against a subquery whose column can be NULL — use NOT EXISTS. (Plain IN is safe; the trap is specific to NOT IN.) If you must keep NOT IN, guard it with WHERE user_id IS NOT NULL inside the subquery — but NOT EXISTS is simpler and faster.

Edge cases

Why doesn’t IN have the same problem? Because x IN (a, b, NULL) is x = a OR x = b OR x = NULL; the NULL term is UNKNOWN, but an OR is true as soon as any real term matches, so a genuine match still surfaces. The asymmetry is the AND in NOT IN: one UNKNOWN poisons a conjunction (true AND UNKNOWN = UNKNOWN), but only a true term is needed to satisfy a disjunction. This is the same three-valued-logic reasoning from the NULL lesson in unit 01 — semi/anti joins are just where it bites hardest in practice.

Quiz

You want each user who has ordered, listed once. A user has 40 orders. Which approach lists that user exactly once without a DISTINCT?

Quiz

u.id NOT IN (SELECT user_id FROM orders) returns zero rows even though many users clearly never ordered. Most likely cause?

Complete the analogy

Fill in the blank: a semi-join returns each qualifying left row exactly _______ no matter how many matches it has on the right — unlike a plain JOIN, which duplicates the left row per match.

Recall before you leave
  1. 01
    What is a semi-join and why is it better than JOIN + DISTINCT for 'users who have ordered'?
  2. 02
    Explain precisely why NOT IN returns zero rows when the subquery contains a NULL.
  3. 03
    Why is NOT EXISTS immune to the NULL trap, and what's the rule of thumb?
Recap

Semi- and anti-joins answer existence questions without combining rows. A semi-join (EXISTS / IN) returns each left row that has at least one match exactly once — a membership test, no fan-out — which is why it beats JOIN ... DISTINCT (no wide intermediate result to collapse, and EXISTS short-circuits on the first match). An anti-join (NOT EXISTS) returns the complement: left rows with no match, equivalent to LEFT JOIN ... WHERE o.id IS NULL. The trap that makes this lesson essential is NOT IN against a nullable subquery: a single NULL turns the implicit x <> a AND x <> b AND x <> NULL into a never-true predicate (three-valued logic), and the query silently returns zero rows. Plain IN is safe because its OR only needs one true term; NOT IN’s AND is poisoned by one UNKNOWN. The production rule is blunt: write anti-joins as NOT EXISTS. In EXPLAIN, these appear as Semi Join and Anti Join nodes — the planner’s own confirmation that you expressed existence, not combination. Now when you see a NOT IN subquery, you’ll check the column’s nullability before trusting the result — because one silent NULL collapses the entire output.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.