skip to content

Why does adding WHERE o.status = 'PAID' to a LEFT JOIN drop customers with no orders?

level: middleimportance: must knowfreq 85%

answer

  1. the join keyword is not the last word
  2. unmatched rows are added back before WHERE runs
  3. what does NULL = 'PAID' evaluate to?
  4. WHERE keeps only TRUE, not UNKNOWN
  5. the fix is placement, not a different join type

basics

~20 s

Unmatched customers are NULL-extended, so o.status is NULL in those rows; NULL = 'PAID' evaluates to UNKNOWN, and WHERE keeps only rows where the predicate is TRUE. The filter therefore deletes every unmatched row, collapsing the LEFT JOIN into an INNER JOIN.

solid answer

~40 s

A `LEFT JOIN` builds its result in two stages: first it keeps the pairs where the `ON` condition is TRUE, then it adds back every left row that found no partner, filling the right table's columns with NULL. `WHERE` runs after that. In a NULL-extended row, `o.status` is NULL, so `o.status = 'PAID'` evaluates to UNKNOWN — not TRUE — and `WHERE` drops the row. The rows that remain are exactly the matched ones, which is what an `INNER JOIN` would have produced. The fix is placement, not a different operator: move the predicate into the `ON` clause, `LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'PAID'`. Now the status test decides which orders count as matches, so a customer with no paid order still appears once, with NULL order columns.

code

sql · 6 lines
sql
-- BUG: LEFT JOIN collapsed to INNER JOIN
-- customers with no PAID order disappear entirely
SELECT c.name, o.id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PAID';

go deeper

for a junior

Recognise the shape on sight: a LEFT JOIN with a WHERE predicate on the right-hand table's columns behaves like an INNER JOIN. Know the fix is to move that predicate into the ON clause.

for a middle

Walk the interviewer through the mechanism: matches, then NULL-extension of unmatched left rows, then WHERE — where a comparison against a NULL column yields UNKNOWN and the row is dropped because WHERE keeps only TRUE.

for a senior

Show how you catch it in real code and in review: compare result counts against the preserved table's row count, spot outer-joined aliases referenced in WHERE, and insist the query says INNER JOIN when the collapse is intended.

for a principal

Treat it as a correctness class, not a puzzle: silently-dropped rows in a report look like a business fact rather than a bug. Argue for regression tests that assert zero-activity entities still appear in every all-entities report.

## The symptom Somebody writes a report that must list every customer, paid orders included where they exist. They start from a `LEFT JOIN`, which does the right thing, then add a status filter in `WHERE` — and the customers with no orders vanish. The `LEFT JOIN` is still in the text, so the query looks correct on a skim. This is probably the single most-asked SQL trap in interviews, and it is asked because it is one of the most common real bugs in reporting code. ```sql -- collapses: only customers with at least one PAID order survive SELECT c.name, o.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'PAID'; ``` ## Why it happens, step by step A joined table is defined in stages, and the `LEFT JOIN` has one stage that the `INNER JOIN` does not: 1. Pair each customer with each order and keep the pairs for which the `ON` condition is TRUE. A customer with no orders — or whose orders all belong to someone else — produces no surviving pair. 2. **NULL-extension.** Every left row that produced no surviving pair is added back to the result once, with every column of `orders` set to NULL. This is the step that makes the join "outer". 3. `WHERE` is applied to the rows from step 2. In step 3 the NULL-extended row is just a row like any other, and it is tested like any other. `o.status` is NULL there, and comparing NULL to anything with `=` yields UNKNOWN, SQL's third truth value. `WHERE` retains a row only when its predicate is TRUE; FALSE and UNKNOWN are both discarded. So every NULL-extended row is removed, and what is left is precisely the set of matched pairs that also pass the filter — the inner join result. The `LEFT JOIN` keyword did its job and was then undone one clause later. Note that it is not only `=`. `o.total > 100`, `o.status <> 'CANCELLED'`, `o.status IN ('PAID','SHIPPED')` and `o.ordered_at >= DATE '2024-01-01'` all return UNKNOWN against NULL and all collapse the join in exactly the same way. The one shape that survives is an explicit null test such as `o.id IS NULL`, which is TRUE for NULL-extended rows — that is why the "rows with no match" idiom works at all, and why it is the exception to remember. ## The fix: move the predicate into ON ```sql -- preserves every customer SELECT c.name, o.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'PAID'; ``` Inside `ON`, the status test is part of deciding what counts as a match. A cancelled order simply is not a match, so a customer whose only order is cancelled produces no surviving pair in step 1 and is therefore NULL-extended in step 2 — one row, name filled in, order columns NULL. The customer stays in the report, correctly showing that they have no paid order. This is the general recipe: **a filter on the optional (NULL-extendable) side of an outer join belongs in `ON` if you want unmatched rows kept, and in `WHERE` only if you deliberately want them gone.** A filter on the preserved side is a different matter — it belongs in `WHERE`, where it removes the rows you actually meant to remove. ## Reading the difference in the output The two versions differ in row count and in shape. Suppose ten customers, six of whom have at least one paid order, two of whom have only cancelled orders, and two of whom have no orders at all. The `WHERE` version returns rows for six customers. The `ON` version returns rows for all ten: the six with their paid orders, and four single rows with NULL order columns. If your report must show "0" or "—" for inactive customers, only the second version can produce it. ## Deliberate collapse is legitimate Sometimes the collapsing form is exactly what you want, and then the honest thing is to write `INNER JOIN`. Leaving `LEFT JOIN` in the text while filtering the optional side in `WHERE` is misleading to the next reader: it advertises an intent the query does not have. In review, treat `LEFT JOIN` plus a `WHERE` predicate on that alias as either a bug or a keyword that should be changed. ## What interviewers listen for The answer they want has three parts: the NULL-extension step, the fact that the comparison against NULL yields UNKNOWN and `WHERE` keeps only TRUE, and the fix by moving the predicate into `ON`. Candidates who say only "WHERE turns it into an inner join" have memorised the outcome; candidates who mention `IS NULL` as the surviving shape have actually thought about it.

  • Which WHERE predicate on the optional side does not collapse a LEFT JOIN?
    An explicit null test such as `WHERE o.id IS NULL`. It is TRUE for NULL-extended rows, so those rows survive — in fact only they survive, which is why that shape is used to find left rows with no match. Every comparison-style predicate (`=`, `<>`, `>`, `IN`, `BETWEEN`) returns UNKNOWN against NULL and removes them.
  • After moving the status filter into ON, how many rows does a customer with three paid orders and two cancelled ones produce?
    Three. The `ON` clause now treats only paid orders as matches, so the customer pairs with its three paid orders and the cancelled ones contribute nothing. NULL-extension applies only when a left row has zero surviving matches, so this customer gets no extra NULL row.
  • If the collapse is what you actually want, what should the query say?
    Write `INNER JOIN` explicitly. Keeping `LEFT JOIN` while filtering the optional alias in `WHERE` produces the inner-join result but tells the next reader the opposite, and it invites someone to "fix" the filter later and change the report's meaning.

ON decides who gets a partner; WHERE decides who stays in the room. An outer join hands the partnerless a stand-in made of NULLs — and any question you ask the stand-in comes back "unknown", so the bouncer at the WHERE door turns them away.

saying these in an interview costs you the question

  • Says WHERE runs before the join, so NULLs never appear
  • Thinks NULL = 'PAID' is FALSE and that FALSE rows are kept
  • Claims LEFT JOIN guarantees every left row regardless of WHERE
  • Proposes COALESCE in the SELECT list as the fix
  • Suggests switching to FULL OUTER JOIN to bring the rows back

context