skip to content

In a LEFT JOIN, does WHERE (o.status = 'PAID' OR o.id IS NULL) equal putting the status test in ON?

level: seniorimportance: nice to knowfreq 28%

answer

  1. think about a customer whose orders all fail the filter
  2. WHERE can only choose among rows that already exist
  3. NULL-extension is decided by ON, before WHERE runs
  4. the guard rescues no-match rows, not failed-match rows

basics

~20 s

No. The two agree only for left rows with no match at all. A customer whose orders are all unpaid survives the ON version as a NULL-extended row, but the OR guard drops it, because its rows carry a non-NULL o.id.

solid answer

~50 s

The `OR o.id IS NULL` guard is a common attempt to "rescue" the rows a `WHERE` filter would collapse, and it rescues the wrong set. `WHERE` sees rows that already exist: the NULL-extended ones, plus every real match. The guard keeps the NULL-extended rows and the paid matches — but a customer whose orders are *all* cancelled produced real matches, so no NULL-extended row was ever created for them; their rows have a non-NULL `o.id` and a failing status, and all of them are filtered out. That customer disappears. Moving the status test into `ON` handles the case correctly: cancelled orders stop being matches, the customer gets zero surviving pairs, and NULL-extension gives them their row. The two forms coincide only when every left row either matches nothing or matches only paid orders. Prefer the `ON` placement — it expresses the intent directly.

code

sql · 14 lines
sql
-- Customer 7 has exactly two orders, both 'CANCELLED'

-- A: filter in ON -> customer 7 returned once, order columns NULL
SELECT c.id, o.id
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status = 'PAID';

-- B: OR-guard in WHERE -> customer 7 is missing entirely
SELECT c.id, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PAID' OR o.id IS NULL;

go deeper

for a junior

You are unlikely to be asked this, but remember the safe default: to keep unmatched rows while filtering the optional side, put the filter in the ON clause rather than patching WHERE.

for a middle

Be able to name the case that splits the two: a left row whose matches all fail the filter. Explain that such a row was never NULL-extended, so the IS NULL branch cannot rescue it.

for a senior

Show the evaluation-order reasoning: the ON condition determines which left rows get NULL-extended, and that decision is already made by the time WHERE runs, so WHERE can only select among existing rows.

for a principal

Treat this as a review pattern worth naming — an OR ... IS NULL guard in WHERE almost always signals a mis-fixed outer-join collapse, and the resulting data loss is silent and dataset-dependent, so it survives small test fixtures.

## Where the guard comes from Someone hits the classic collapse — `LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'PAID'` losing customers — and reaches for the smallest edit that brings rows back: ```sql WHERE o.status = 'PAID' OR o.id IS NULL ``` It is a reasonable-looking patch, because `o.id IS NULL` really is the one predicate shape that is TRUE for NULL-extended rows. The output row count goes up, some of the missing customers return, and the change ships. It is still wrong, and the wrongness is data-dependent, which is what makes it worth an interview question. ## Reasoning about the two forms row by row Group the customers by what their orders look like. **Customer with no orders at all.** The join produces no pair. NULL-extension creates one row with `o.id` NULL. Under the `ON` version it survives, since nothing filters it. Under the guard it survives too, via the `IS NULL` branch. **Same.** **Customer with at least one paid order.** Both forms return that customer's paid orders — the `ON` version by pairing only with paid orders, the guard by keeping the paid rows and discarding the rest. **Same.** **Customer whose orders exist but none is paid.** Here they diverge. Under the `ON` version, no cancelled order qualifies as a match, so the customer has zero surviving pairs and is NULL-extended: one row, blank order columns, customer present. Under the guard, the join *did* produce pairs — real cancelled orders with a non-NULL `o.id` — so no NULL-extended row was ever created. Every one of those rows fails `o.status = 'PAID'` and fails `o.id IS NULL`. All are discarded and **the customer vanishes**. So the guard is equivalent to the `ON` placement exactly when no left row has matches that all fail the filter. In a `customers`/`orders` schema that condition is essentially never guaranteed, which is why the patch behaves correctly in a small test dataset and wrongly in production. ```sql -- customer 7 has exactly two orders, both 'CANCELLED' -- A: keeps customer 7 (one NULL-extended row) SELECT c.id, o.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'PAID'; -- B: loses customer 7 entirely SELECT c.id, o.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'PAID' OR o.id IS NULL; ``` ## Why the difference is structural, not cosmetic NULL-extension is decided *before* `WHERE` runs, on the basis of the `ON` condition alone. Once the join has produced real matched rows for a left row, that left row is not a candidate for NULL-extension any more — no later clause can put it back. `WHERE` can only select among rows that exist; it cannot manufacture the NULL-extended row that a stricter `ON` clause would have created. That is the entire asymmetry, and it is why "filter in `ON`" and "filter in `WHERE` with a null-guard" are different operations rather than two spellings of one. A useful mental summary: `ON` changes *what counts as a match*, so it changes which rows get NULL-extended. `WHERE` changes *which existing rows are returned*, and by then the set of NULL-extended rows is already fixed. ## Choose the guarded column carefully — but that is not the real problem One secondary trap: the guard must test a column that cannot be NULL in real data, typically the right table's primary key. Guarding on a nullable column such as `o.note` keeps genuinely-matched rows that merely happen to have a NULL note, mixing source NULLs with join-produced NULLs. Fixing that choice, however, does not fix the structural problem above — the customer with only cancelled orders is still lost. ## When the guard is nonetheless the right tool There is a legitimate use for `OR <key> IS NULL` in `WHERE`: when the intent really is "rows that matched and pass the filter, **plus** rows that matched nothing at all", and you specifically do *not* want to rescue left rows whose matches all failed. That is a narrow requirement, and if you write it, say so in a comment, because every reviewer will otherwise read it as the mis-patched collapse. ## What interviewers listen for The expected answer is "no", followed by the exact dividing case: a left row with matches that all fail the predicate. Candidates who can construct that row on the spot — customer 7, two cancelled orders — have genuinely understood outer-join evaluation order rather than memorised a fix. Bonus points for noting that `WHERE` cannot create NULL-extended rows the join never produced.

  • Construct the smallest dataset that distinguishes the two queries.
    One customer and one order belonging to it with status 'CANCELLED'. The `ON`-placement query returns one row: the customer with NULL order columns. The `WHERE ... OR o.id IS NULL` query returns zero rows, because the single joined row has a non-NULL `o.id` and a status that is not 'PAID'.
  • Which column should the IS NULL guard test, and why does the choice matter?
    A column of the right table that is never NULL in stored data — usually its primary key. Guarding on a nullable column like `o.note` also keeps genuinely matched rows whose note happens to be NULL, so you can no longer tell join-produced NULLs from source NULLs. It still does not close the structural gap.
  • Is there a requirement the OR guard expresses that ON placement cannot?
    Yes: "matched rows that pass the filter, plus left rows that matched nothing at all, but not left rows whose matches all failed." `ON` placement always rescues the third group. The requirement is unusual, so write it with a comment explaining that the exclusion is deliberate.

saying these in an interview costs you the question

  • Claims the OR guard is exactly equivalent to filtering in ON
  • Thinks WHERE can recreate rows the join never produced
  • Tests the guard only on rows that have no matching child at all
  • Guards on a nullable column instead of the right table's key
  • Says both forms return the same count so the difference is cosmetic

context