skip to content

A nightly job's NOT IN filter suddenly returns zero rows after a data change — how do you diagnose and permanently fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. check the subquery column first
  2. one bad value is enough to break it
  3. empty result, no error at all
  4. the fix belongs in the schema too
  5. two-valued predicate cannot be poisoned

basics

~20 s

Check whether the subquery column now contains NULLs: one NULL makes every NOT IN comparison UNKNOWN, so the filter matches nothing. Fix durably by rewriting as NOT EXISTS and, where the model allows, declaring the column NOT NULL.

solid answer

~50 s

Diagnose it by running `SELECT COUNT(*) FROM t WHERE col IS NULL` on the subquery's column — a single NULL is enough. `NOT IN` expands to `<> ALL`, and a comparison against NULL yields UNKNOWN, so no candidate can satisfy "differs from every value" and `WHERE` discards the lot. The failure is silent: no error, no warning, just an empty result, which is why it survives to production and shows up as "the job processed nothing last night". The durable fixes, in order of strength: make the column `NOT NULL` if the domain allows it; rewrite the predicate as `NOT EXISTS` with the correlated comparison, which is two-valued and therefore immune; and only as a last resort add `WHERE col IS NOT NULL` inside the subquery — correct, but easy for a later edit to lose. Then add a fixture row with a NULL so the regression test would have caught it.

code

sql · 11 lines
sql
-- 1. Confirm the cause in one query
SELECT COUNT(*) AS null_ids
FROM customers_active
WHERE id IS NULL;

-- 2. NULL-safe rewrite of the filter
SELECT o.id
FROM orders o
WHERE NOT EXISTS (
  SELECT 1 FROM customers_active c WHERE c.id = o.customer_id
);

go deeper

for a junior

Recall the association: an anti-filter returning nothing points at NULLs in the subquery. Knowing to run a NULL count on that column is already a useful contribution.

for a middle

Explain the mechanism — NOT IN is <> ALL, NULL comparisons are UNKNOWN, WHERE drops UNKNOWN — and produce the NOT EXISTS rewrite correctly, with the correlated predicate in the right place.

for a senior

Show the whole loop: notice, confirm with one query, patch, then fix the cause in the schema and add a NULL-carrying fixture. Explain why the failure is silent and why that makes it a wrong-answer class rather than a bug.

for a principal

Own the systemic response: a review convention for anti-filters, nullability decided deliberately in the schema, and output-volume assertions on batch jobs so silent zero-row runs surface as incidents instead of as nothing at all.

## The symptom A reconciliation or cleanup job that has run correctly for months processes zero rows. Nothing failed; the query returned an empty result and the job dutifully did nothing. The query looks like this: ```sql SELECT o.id FROM orders o WHERE o.customer_id NOT IN (SELECT id FROM customers_active); ``` Something upstream changed — a column became nullable, a view started emitting NULLs, an import wrote a NULL id, a `LEFT JOIN` inside the source view began NULL-extending. Now the subquery yields at least one NULL and the filter matches nothing. ## Diagnose in one query ```sql SELECT COUNT(*) AS null_ids FROM customers_active WHERE id IS NULL; ``` If that count is greater than zero, you have your cause and you do not need to look further. It is worth making this the *first* check whenever an anti-filter returns empty, before profiling anything, because it is a one-line test with a definitive answer. ## The mechanism, briefly `x NOT IN (S)` is defined as `x <> ALL (S)`: TRUE only when `x <> v` is TRUE for every v in S. Comparing to NULL yields UNKNOWN rather than TRUE, and a universal claim with an undecided conjunct is undecided. `WHERE` keeps only TRUE, so every candidate is dropped — the matching ones because the predicate is FALSE, the non-matching ones because it is UNKNOWN. The result is empty rather than merely wrong, which is the tell. ## The fix ladder **1. Fix the data model.** If `customers_active.id` should never be NULL, declare it `NOT NULL` (or fix the view that manufactures the NULL). This makes the predicate unambiguous at the source and protects every other query that reads the same column. It is the only fix that is impossible to lose in a later refactor of the query. **2. Rewrite as `NOT EXISTS`.** ```sql SELECT o.id FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM customers_active c WHERE c.id = o.customer_id ); ``` Inside the subquery, a NULL id makes `c.id = o.customer_id` UNKNOWN, so that row simply is not a match. EXISTS then answers a definite yes or no about row count, so `NOT EXISTS` can never be UNKNOWN. This is correct whether or not the column is nullable, which is why it is the right default habit for anti-filters even in schemas you believe are clean. **3. Filter inside the subquery.** ```sql ... NOT IN (SELECT id FROM customers_active WHERE id IS NOT NULL) ``` This is semantically correct and sometimes the smallest safe patch during an incident. Its weakness is durability: the `IS NOT NULL` looks like an incidental filter, carries no explanation, and is exactly the sort of clause a later edit drops while "simplifying". If you use it, comment why. Note what does **not** work: coalescing the outer column (`COALESCE(o.customer_id, -1) NOT IN ...`) changes nothing, because the poisoning NULLs are on the subquery side. Neither does `DISTINCT` in the subquery, nor swapping to `<> ANY` — which is a different predicate entirely and is TRUE almost always. ## Close the loop After the fix, add a fixture row with a NULL in the subquery column to the test that covers this job. The original test passed because the test data was clean; the class of bug is "correct on clean data, silently empty on real data", so only a NULL-carrying fixture reproduces it. Where the job's output is an action rather than a report, a sanity assertion — "this job should never process exactly zero rows two nights running" — turns the silent failure into an alert. ## Prevention as a rule Most teams that have hit this once adopt a standing convention: anti-filters are written with `NOT EXISTS`, and `NOT IN` over a subquery is flagged in review unless the column is provably `NOT NULL`. The rule is cheap because the rewrite is mechanical and reads at least as well, and it removes a defect class that produces no error message at all. In an interview, this is the part that separates "I know the trap" from "I have operated systems that hit it": say how you would notice, how you would confirm, what you would change in the schema, and what would have caught it earlier.

  • Why does this failure produce an empty result rather than an error or a partially wrong one?
    Because NULL comparisons are legal SQL that evaluate to UNKNOWN, and WHERE keeps only TRUE. Matching candidates are rejected as FALSE, non-matching ones as UNKNOWN, so nothing survives and no engine has any reason to complain. That total-silence property is what lets it reach production and why the first diagnostic should be a NULL count on the subquery column.
  • Why is adding IS NOT NULL inside the subquery considered the weakest of the durable fixes?
    It is semantically correct but fragile. The clause carries no explanation, looks like an ordinary filter, and survives only as long as nobody tidies the query. A NOT NULL constraint fixes the cause for every reader of that column, and NOT EXISTS is correct regardless of nullability. Use IS NOT NULL as an incident patch and follow it with one of the other two.
  • What test would have caught this before deployment?
    One whose fixture includes a row with NULL in the subquery column. The original test passed because the seed data was clean, and the defect class is precisely "correct on clean data, silently empty on real data". For jobs whose output is an action rather than a report, pair that with a sanity assertion on the processed-row count so a zero-row night raises an alert.

saying these in an interview costs you the question

  • Blames the optimizer or a plan change instead of the data
  • Says the query would have errored if NULLs were involved
  • Patches only the outer column with COALESCE
  • Calls IS NOT NULL inside the subquery a permanent fix
  • Never checks whether the subquery column is nullable

context