FDEInterviews logo
SQL & Data Engineering / 05
easyRetoolMetaMicrosoft

Why did my LEFT JOIN start behaving like an INNER JOIN after I added a WHERE filter?

The most common bug in analyst SQL, and a favorite warm-up in Retool's debugging-flavored screens. One word's placement, ON vs WHERE, silently deletes your unmatched rows.

Updated Aug 2026 · Grounded in real Forward Deployed Engineer interview loops and written to a senior-engineer editorial bar.

TL;DR: The LEFT JOIN pads unmatched right-side columns with NULL, then a WHERE predicate on those columns evaluates to UNKNOWN and discards the row, collapsing the outer join to an inner one. Move the right-table condition into the ON clause. The one exception is a deliberate IS NULL anti-join.

How to approach it

This is a conceptual-debugging question: explain the mechanism, show the fix, and name the general rule. A great answer takes 90 seconds and includes a two-line code contrast. If it's posed as "find the bug in this query," read the WHERE clause for any condition on the right (nullable) table first.

A strong answer

A LEFT JOIN preserves unmatched left-side rows by filling the right side's columns with NULL. If you then filter on a right-side column in WHERE:

SELECT c.customer_id, o.order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'completed';   -- bug

…every preserved row has o.status = NULL, and NULL = 'completed' evaluates to UNKNOWN, which WHERE treats as false. All the customers-without-orders you used the LEFT JOIN to keep are silently dropped, you've reinvented an INNER JOIN.

The fix depends on intent:

-- Intent: keep all customers, but only join completed orders
SELECT c.customer_id, o.order_id
FROM customers c
LEFT JOIN orders o
  ON o.customer_id = c.customer_id
 AND o.status = 'completed';     -- filter in ON

-- Intent: keep unmatched rows AND allow the filter
WHERE o.status = 'completed' OR o.order_id IS NULL;

Three customers are enough to watch the collapse happen (all three queries executed against the same fixture). Ana has a completed and a pending order, Bo has only a pending order, Cy has none:

plain LEFT JOIN:          (Ana, 10) (Ana, 11) (Bo, 12) (Cy, NULL)   -- 4 rows, all customers
+ WHERE status='completed': (Ana, 10)                               -- 1 row: Bo AND Cy gone
+ status moved into ON:    (Ana, 10) (Bo, NULL) (Cy, NULL)          -- 3 rows, all customers back

The middle line is the bug at full size: not only did Cy (no orders) vanish, so did Bo, whose only order failed the filter. A report titled "customers and their completed orders" just became "customers who have completed orders," and if it feeds a churn dashboard, the customers most likely to be churning are exactly the ones the query dropped. The third line is the fix keeping the promise: every customer appears, with order columns populated only where a completed order exists. This is also the row-count sanity check from the probes made concrete: the left table has 3 rows, so any result with fewer than 3 means the outer join is quietly inner.

The rule worth stating crisply: for outer joins, conditions on the inner (nullable) side belong in ON; conditions on the preserved side can live in WHERE. For INNER JOINs, ON vs WHERE placement doesn't change results (only readability), which is exactly why people develop the bad habit.

Bonus depth: the same NULL logic explains the NOT IN trap, WHERE id NOT IN (SELECT ref_id ...) returns zero rows if the subquery yields any NULL, so prefer NOT EXISTS or an anti-join (LEFT JOIN ... WHERE right.key IS NULL). Mentioning this unprompted shows you've actually debugged production SQL.

AFTER THE LEFT JOIN matched rows, right columns populated unmatched left rows, right columns NULL WHERE r.status = 'x' AFTER THE WHERE matched rows survive NULL = 'x' is UNKNOWN, so dropped The fix depends on what you meant Filter the right table BEFORE joining: move the condition into the ON clause. Filter the result but keep unmatched rows: WHERE (r.status = 'x' OR r.id IS NULL). You genuinely wanted an inner join: write INNER JOIN and say so, so the next reader knows.

What interviewers probe next

  • "When would you deliberately put a left-table filter in ON?", rare and confusing; it doesn't drop left rows in a LEFT JOIN, it just nulls the right side, so prefer WHERE for clarity.
  • "How would you detect this bug in a deployed dashboard?", row-count sanity check: compare COUNT(*) against the left table alone; an unexpected drop after a LEFT JOIN means the filter or join is wrong.
  • "Three-valued logic", TRUE/FALSE/UNKNOWN, and that NULL = NULL is UNKNOWN, motivating IS NULL / IS NOT DISTINCT FROM.
  • Often chained into the join fan-out question (rows multiplying instead of disappearing).

Common mistakes

  • Saying "WHERE runs before the join", backwards; the filter runs after, which is precisely why it kills the NULL-padded rows.
  • Fixing with COALESCE(o.status,'completed') style hacks instead of moving the predicate.
  • Not asking which behavior the user actually wanted, the fix differs.
  • Forgetting the same trap exists in HAVING and in NOT IN.
That one was free — and so are 10 answers per topic without an account. Signing in doubles that to 20, opens the Plus lessons in the courses, and remembers which topics you keep getting wrong.no card · Google sign-in · nothing to cancel
HOW DID IT GO?
0
READING SIGNED OUT

Signing in doubles your free answers, from 10 to 20 per topic, and the site starts remembering you: mastery per topic, bookmarks, and a next-focus recommendation. Free, no card.

Sign in free
UP NEXT ON YOUR JOURNEY
FEDITOR'S NOTE

The mechanism worth stating crisply is that the LEFT JOIN fills unmatched right-side columns with NULL, and any WHERE predicate on those columns then evaluates to NULL, which is not true, so the row is discarded and your outer join collapses to an inner one. The fix is to move the right-table condition into the ON clause, and the senior tell is naming the one legitimate exception, an explicit IS NULL anti-join, where filtering on the NULL is the whole point.

DISCUSSION · 0

No comments yet — be the first to share your approach.