Why does a join chain that starts with LEFT JOIN lose the preserved rows once an INNER JOIN follows?
answer
- joins in a FROM clause run in order
- the second join sees the first join's output
- its predicate meets NULL-extended columns
- unknown is not true, so the row is dropped
basics
~20 sA join chain is evaluated left to right, so the inner join runs against the already NULL-extended intermediate result. Its predicate compares a NULL column, evaluates to unknown, and the preserved rows are discarded — turning the whole chain inner.
solid answer
~50 s`FROM a LEFT JOIN b ON … JOIN c ON c.x = b.x` is not three peers; it is `(a LEFT JOIN b) INNER JOIN c`. The first join preserves every `a` row, NULL-extending `b`'s columns where there was no match. The second join then demands a true predicate for every row it keeps, and for those NULL-extended rows `c.x = b.x` compares against `NULL`, which is unknown, not true. Those rows are dropped, so the report that promised "every customer" silently shows only customers with orders. The cure depends on intent. If `c` is genuinely optional, make it `LEFT JOIN` too — then unmatched rows survive with `c` NULL-extended as well. If the `b`–`c` relationship must stay inner, join those two first inside a derived table and `LEFT JOIN` that unit onto `a`. Outer joins are order-sensitive, so where you put the parentheses is part of the semantics.
code
sql · 6 lines-- loses customers with no orders: the inner join's
-- predicate compares against a NULL-extended column
SELECT c.name AS customer, p.name AS product
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
JOIN products p ON p.id = o.product_id;go deeper
Know that joins are applied in the order written and that a later INNER JOIN can remove rows an earlier LEFT JOIN preserved. Recognising the symptom is enough at this level.
Explain the mechanics: the chain groups as (a LEFT JOIN b) JOIN c, and the inner join's predicate against NULL-extended columns is unknown rather than true, so those rows are discarded.
Diagnose it on a real report — count driving-table keys before and after, peel joins from the bottom up — and choose between staying outer and isolating the inner pair in a derived table based on what the data actually requires.
Make the class of bug hard to reintroduce: a convention that the driving table is named first, that every join after the first outer join is justified as required or optional, and a row-count reconciliation in the reporting suite that fails when a driving-table count silently drops.
## A FROM clause is a chain, not a set SQL evaluates a sequence of joins left to right. `FROM a LEFT JOIN b ON p1 JOIN c ON p2` means `((a LEFT JOIN b ON p1) INNER JOIN c ON p2)`. The second join's left input is not table `a` — it is the *result* of the first join, rows and NULL-extensions included. Reading the clause as "three tables joined together" is what makes the bug invisible. ## The mechanism of the loss ```sql SELECT c.name AS customer, p.name AS product FROM customers c LEFT JOIN orders o ON o.customer_id = c.id JOIN products p ON p.id = o.product_id; -- INNER ``` A customer with no orders reaches the second join as a row where every `orders` column is `NULL`. The inner join's predicate is then `p.id = NULL`, which evaluates to **unknown**. A join keeps only pairings whose predicate is true, so no pairing survives for that row, and an inner join has no preservation rule to fall back on. The row disappears. Repeat for every orderless customer and the `LEFT JOIN` on line two has been reduced to an inner join by a line written after it. Note the precise trigger: the inner join's predicate references a column from the NULL-extended side. If the predicate referenced only `customers`, the row could still survive — provided `products` had a match for it. But an inner join anywhere downstream can always remove preserved rows, because "must match" beats "was preserved". ## Fix 1 — make the rest of the chain outer If `products` is optional from the report's point of view: ```sql FROM customers c LEFT JOIN orders o ON o.customer_id = c.id LEFT JOIN products p ON p.id = o.product_id; ``` Now the orderless customer survives with both `orders` and `products` columns `NULL`. This is worth understanding rather than cargo-culting: the second `LEFT JOIN` still finds no match (its predicate is still unknown), but the outer join's preservation rule NULL-extends instead of discarding. The rule of thumb — "once you go outer, stay outer down the chain" — falls out of this. ## Fix 2 — group the inner relationship first Sometimes `orders` and `products` genuinely belong together: an order without a product is a data error, and you do not want a half-populated row. Then join them as a unit and attach the unit: ```sql FROM customers c LEFT JOIN ( SELECT o.customer_id, p.name AS product FROM orders o JOIN products p ON p.id = o.product_id ) op ON op.customer_id = c.id; ``` The inner join happens entirely inside the derived table; the outer join sees a single optional input. Standard SQL also allows a parenthesised join expression in the `FROM` clause for the same effect. ## Why the parentheses matter Inner joins are associative and commutative, so their order is free — an optimizer reorders them at will. Outer joins are not. `(a LEFT JOIN b) LEFT JOIN c` and `a LEFT JOIN (b LEFT JOIN c)` can return different results, and moving an inner join across an outer join changes the answer, as this whole question demonstrates. When you write a mixed chain, the textual order **is** the specification. ## Diagnosing it in the wild The symptom is a report that used to include "all customers" and now silently includes fewer, usually after someone added a lookup table to enrich the output. The fastest confirmation is a row count: count the driving table alone, then count the joined query's distinct driving-table keys. If the second number is smaller, some join below the first outer join is filtering. Comment out the joins from the bottom up until the count recovers; the line that restores it is the culprit. A good habit when writing these: after the first `LEFT JOIN`, treat every later join as a decision that must be justified out loud — "is this table required, or optional?" Required means you accept losing preserved rows, and if you do not accept that, the required table belongs inside a derived table instead. ## The interview answer State the grouping — the chain is `(a LEFT JOIN b) JOIN c` — explain that the inner join's predicate evaluates to unknown against NULL-extended columns so those rows are discarded, then give both fixes and say which one you would pick based on whether the third table is optional or part of an inseparable pair.
- In a LEFT JOIN b ON … LEFT JOIN c ON c.id = b.c_id, what do c's columns hold for rows where b did not match?NULL. The predicate compares `c.id` against a NULL-extended `b.c_id`, so it is unknown and matches nothing — but because this join is outer, the row is preserved and `c` is NULL-extended in turn. The row survives with both `b` and `c` columns NULL, which is usually exactly what the report wants.
- How do you keep every customer while still requiring each order to have a product?Join `orders` to `products` inside a derived table or a parenthesised join expression, then `LEFT JOIN` that unit onto `customers`. The inner requirement is enforced within the unit, and the outer join sees one optional input, so orderless customers are still preserved with the whole unit NULL-extended.
- Why can an optimizer freely reorder inner joins but not outer joins?Inner joins are associative and commutative, so any order yields the same rows. Outer joins are not: `(a LEFT JOIN b) LEFT JOIN c` and `a LEFT JOIN (b LEFT JOIN c)` can differ, and moving an inner join across an outer join changes which rows are preserved. In a mixed chain, the written order is part of the meaning.
- How would you confirm quickly that a join, and not a WHERE clause, is losing the rows?Count the driving table on its own, then count the distinct driving-table keys the joined query returns. If the second is smaller, something below is filtering. Remove joins from the bottom of the chain upward until the count recovers — the line that restores it is the one that turned the chain inner.
The chain is an assembly line, not a committee. Once the first station lets an incomplete part through with blank fields, a later station that rejects blanks throws it away — and the guarantee made at the first station is gone.
saying these in an interview costs you the question
- Says the order of joins in a FROM clause never matters
- Thinks NULL = NULL matches, so the inner join should keep the rows
- Blames the SELECT list or a missing DISTINCT for the missing rows
- Adds another LEFT JOIN at random without identifying which join filtered
- Believes outer joins are associative like inner joins