A LEFT JOIN report lost its zero-activity rows after a WHERE date filter was added — how do you diagnose and fix it?
answer
- compare result rows against the preserved table's count
- find one entity you know should appear
- which alias does each WHERE predicate name?
- a range filter on the optional side is UNKNOWN on NULLs
- move the whole range, not just one bound
basics
~20 sLook for WHERE predicates naming the outer-joined alias: a date filter on the optional table discards NULL-extended rows and collapses the LEFT JOIN. Move the date range into the ON clause, then assert that every preserved-side row still appears.
solid answer
~50 sStart from the row counts. If the report is meant to list every customer, compare the distinct customers in the result with the count in `customers`; a shortfall confirms rows were filtered, not missing upstream. Then scan the `WHERE` clause for any predicate that names the outer-joined alias — `o.ordered_at >= DATE '2024-01-01'` is one. Against a NULL-extended row that comparison is UNKNOWN, so `WHERE` drops it and the `LEFT JOIN` degenerates to an inner join. Confirm by deleting the predicate and watching the missing customers return. The fix is to move the whole date range into the `ON` clause so it constrains which orders match rather than which rows survive. Keep genuinely preserved-side filters (customer status, region) in `WHERE`. Then lock it down with a test asserting that a customer with no orders in range still appears.
code
sql · 7 lines-- BUG: the range filter discards NULL-extended rows,
-- so customers with no 2024 order vanish from the report
SELECT c.id, c.name, o.id AS order_id, o.ordered_at
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.ordered_at >= DATE '2024-01-01'
AND o.ordered_at < DATE '2025-01-01';go deeper
Know the first check to run: does the WHERE clause mention a column from the outer-joined table? If so, that is very likely why the zero-activity rows are gone.
Be able to explain and apply the fix: a date range on the optional side must move in its entirety into the ON clause, while filters on the preserved table stay in WHERE where they do their intended job.
Show a repeatable method — baseline count, a witness row, bisection — and cover the variant where a later INNER JOIN, not the WHERE clause, eliminates the NULL-extended rows. Finish by naming the regression test.
Treat silently-dropped rows as a data-trust incident: the report was wrong while looking right. Argue for invariant tests on all-entity reports and for review rules that flag outer-joined aliases in WHERE before such a query reaches a dashboard.
## The failure mode Reports that must show every entity — every customer, every product, every region — are built on outer joins precisely so that entities with no activity still get a line. The bug class is that someone later adds a perfectly reasonable filter ("this quarter only", "exclude cancelled", "amount over zero") in the `WHERE` clause, and the zero-activity lines silently disappear. Nothing errors. The report still renders. It simply stops being a report about *every* customer, and the reader interprets the absence as a business fact. ```sql -- looks fine; quietly drops customers with no 2024 orders SELECT c.id, c.name, o.id AS order_id, o.ordered_at FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.ordered_at >= DATE '2024-01-01' AND o.ordered_at < DATE '2025-01-01'; ``` ## Diagnosis, in order **1. Establish the expected cardinality.** The report claims one line per customer at minimum. Get the baseline: `SELECT COUNT(*) FROM customers` (plus whatever preserved-side filter the report legitimately applies). Compare with the customers actually present in the output. A shortfall means the query is filtering them, not that the data is missing. **2. Pick a known-good witness.** Find one customer you can prove exists and has no orders in the range. Its absence from the output is your reproducer; its presence after the fix is your proof. Working from a specific row beats reasoning about counts, especially when other filters are in play. **3. Read the WHERE clause for outer-joined aliases.** This is the mechanical check and it finds the bug most of the time. Any predicate that names a column of the optional side — with `=`, `<>`, `<`, `>`, `BETWEEN`, `IN`, `LIKE` — evaluates to UNKNOWN on NULL-extended rows and removes them. The one exception is an explicit `IS NULL` test, which is TRUE for those rows and keeps them. **4. Bisect.** Remove the suspect predicate and re-run. If the witness reappears, you have confirmed the cause without needing to reason further. Restore it in the right clause and check the witness again. **5. Watch for the same bug arriving through a chained join.** A later `INNER JOIN` onto the optional side does the same damage as a `WHERE` predicate: its `ON` condition can never be TRUE for a NULL-extended row, so those rows are eliminated further down the `FROM` clause. Diagnosing only the `WHERE` clause misses this variant. ## The fix Move the range into the join condition, so it decides which orders count as matches: ```sql SELECT c.id, c.name, o.id AS order_id, o.ordered_at FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND o.ordered_at >= DATE '2024-01-01' AND o.ordered_at < DATE '2025-01-01'; ``` A customer whose orders all fall outside the window now has no surviving match, so NULL-extension gives them one row with blank order columns — exactly the "no activity" line the report wanted. Note that the fix moves the *entire* range: leaving one bound in `WHERE` reintroduces the collapse, because that single remaining comparison is still UNKNOWN on NULL-extended rows. Filters that belong to the preserved side stay where they are. `WHERE c.status = 'ACTIVE'` reads a column that is never NULL-extended, so it removes exactly the customers you meant and leaves the outer join intact. Sorting predicates by which side they name is the whole discipline. ## Verifying and keeping it fixed After the fix, the result should contain at least one row for every customer the preserved-side filters admit. That is a checkable property, and it is the right regression test: assert that a seeded customer with zero in-range orders appears in the output. Row-count equality is *not* the right assertion, because customers with several in-range orders legitimately produce several rows. It is also worth writing the intent into the SQL. A one-line comment above the join — "date range lives in ON so customers with no 2024 orders still appear" — is the cheapest defence against the next person helpfully "tidying" the predicate back into `WHERE`. ## What interviewers listen for They want a method, not a lucky guess: baseline count, a concrete witness row, a targeted read of `WHERE` for optional-side aliases, bisection to confirm, then a fix expressed as predicate placement rather than as a different join type. Mentioning the chained-inner-join variant, the need to move *all* bounds of a range, and the regression test that keeps the fix in place is what separates a senior answer from a merely correct one.
- You moved one bound of the date range into ON and left the other in WHERE. What happens?The join still collapses. The remaining bound is a comparison against a NULL column in every NULL-extended row, so it evaluates to UNKNOWN and `WHERE` discards those rows exactly as before. Both bounds — and any other optional-side condition — have to move together into the `ON` clause.
- The WHERE clause is clean, yet zero-activity rows are still missing. Where else do you look?At the rest of the `FROM` clause. An `INNER JOIN` chained onto the outer-joined table eliminates NULL-extended rows too, since its join condition can never be TRUE for them. Converting that later join to a `LEFT JOIN`, or nesting the pair inside a parenthesised join or derived table, restores the preserved rows.
- What regression test would you write once the fix is in?Seed a customer with zero orders in the reporting window and assert that the report returns a row for them with NULL order columns. Assert on presence of that witness rather than on total row count, since customers with several in-range orders correctly contribute several rows.
saying these in an interview costs you the question
- Blames missing data and starts hunting for deleted rows
- Adds DISTINCT and calls the row count fixed
- Wraps the filtered column in COALESCE inside WHERE as the fix
- Moves only one bound of the date range into ON
- Asserts total row count is unchanged as proof the fix worked