skip to content

Why does an UPDATE whose SET reads a correlated subquery set unmatched rows to NULL?

level: seniorimportance: must knowfreq 62%

answer

  1. Two clauses, two different jobs
  2. What does a scalar subquery return with no rows?
  3. SET cannot decline to assign
  4. The correlation must be repeated somewhere
  5. EXISTS or COALESCE, chosen deliberately

basics

~20 s

A subquery in SET returns NULL when it finds no matching source row, and the WHERE clause of the UPDATE decides which rows are touched, not whether a match exists. Every row without a match is therefore overwritten with NULL.

solid answer

~40 s

The two clauses answer different questions. `SET col = (SELECT ... WHERE s.id = t.id)` says *what value* each targeted row gets; the `UPDATE`'s own `WHERE` says *which rows* are targeted. A scalar subquery that matches nothing yields `NULL` rather than "skip this row", so if the `WHERE` lets every row through, every unmatched row is faithfully overwritten with `NULL` — a classic way to destroy a column in production. There are two portable fixes. Repeat the correlation as an existence test so unmatched rows are never targeted: `... WHERE EXISTS (SELECT 1 FROM source s WHERE s.id = t.id)`. Or keep the old value with `SET col = COALESCE((SELECT ...), col)`. The `EXISTS` form is usually preferable — it leaves unmatched rows entirely untouched and it shows up honestly in the affected-row count.

code

sql · 8 lines
sql
-- Wrong: employees with no matching department get dept_name overwritten with NULL
UPDATE employees e
   SET dept_name = (SELECT d.name FROM departments d WHERE d.id = e.dept_id);

-- Right: target only rows that actually have a matching source row
UPDATE employees e
   SET dept_name = (SELECT d.name FROM departments d WHERE d.id = e.dept_id)
 WHERE EXISTS (SELECT 1 FROM departments d WHERE d.id = e.dept_id);

go deeper

for a junior

Remember that a subquery in SET always produces a value, and that value is NULL when nothing matches. Selection of rows is the WHERE clause's job.

for a middle

Explain the split of responsibility between SET and WHERE, and write both fixes — the repeated WHERE EXISTS guard and the COALESCE wrapper — describing what each does to unmatched rows.

for a senior

Demonstrate the discipline: preview the correlation as a SELECT, decide deliberately what unmatched rows should do, reconcile the affected-row count afterwards, and recognise that a duplicate source match aborts the statement rather than picking a winner.

for a principal

Treat this as a data-loss class, not a typo: correlated updates against production tables need a mandated preview query, an explicit rule for unmatched rows, and a review standard that the guard predicate matches the SET correlation exactly.

## The pattern A correlated update pulls each target row's new value out of another table: ```sql UPDATE employees e SET dept_name = (SELECT d.name FROM departments d WHERE d.id = e.dept_id); ``` The subquery references `e.dept_id`, a column of the row being updated, which is what makes it *correlated*: conceptually it is evaluated once per target row, with that row's values plugged in. ## Where the damage comes from The statement above has no `WHERE`, so every row of `employees` is a target. For an employee whose `dept_id` matches a department, the subquery returns that name. For an employee whose `dept_id` is `NULL`, or points at a department that no longer exists, the subquery finds no row — and a scalar subquery over an empty result is defined to produce `NULL`, not "no value" and not "leave the column alone". SQL has no notion of an assignment that declines to happen. So the engine dutifully writes `NULL` over whatever `dept_name` held. The realisation to state out loud in an interview is that **the SET subquery cannot filter rows**. Its job is to produce a value; row selection is the `WHERE` clause's job alone. People write the correlation once, in `SET`, and unconsciously expect it to do both jobs. ## Fix one: repeat the correlation in WHERE EXISTS ```sql UPDATE employees e SET dept_name = (SELECT d.name FROM departments d WHERE d.id = e.dept_id) WHERE EXISTS (SELECT 1 FROM departments d WHERE d.id = e.dept_id); ``` Now only employees with a matching department are targeted; the rest keep their old value and are not counted as updated. The duplication of the predicate is ugly but it is the portable ANSI answer, and the two copies must be kept in step — a mismatch between them reintroduces the bug. `EXISTS` is the right existence test here rather than `IN`, because `IN` against a column that can contain `NULL` brings three-valued logic into the picture; `EXISTS` asks only whether a row was found. ## Fix two: COALESCE the old value back in ```sql UPDATE employees e SET dept_name = COALESCE((SELECT d.name FROM departments d WHERE d.id = e.dept_id), e.dept_name); ``` This writes the old value back when there is no match, so nothing is lost. It is shorter, but it has two drawbacks: every row is still targeted, so the affected-row count no longer tells you how many rows genuinely matched, and it cannot distinguish "no matching source row" from "the source row's value really is NULL" — both cases keep the old value. Choose it when the source column is `NOT NULL` and you want the terser statement; choose `EXISTS` when you want the statement to touch only what it should. ## When the subquery matches more than one row A second failure mode of the same pattern: if the correlation is not unique — say two departments share an `id` because the join key is wrong — the scalar subquery returns more than one row and the statement fails at runtime. That is the good outcome, because the alternative (silently picking one) would be non-deterministic. Before writing a correlated update, confirm the correlation predicate genuinely identifies at most one source row; if the source may legitimately have several rows, aggregate deliberately (`SELECT MAX(...)`, `SELECT SUM(...)`) so the intent is written down. ## Updating several columns from the same source Repeating the whole correlated subquery once per assigned column works, but restates the same lookup several times. The standard row-assignment form `SET (a, b) = (SELECT x, y FROM ... WHERE ...)` expresses the single lookup once; engine support for it varies, so check before relying on it. Some engines also offer a join-style update or `MERGE`, which express "only rows that matched" directly. ## A safe procedure 1. Write the correlation as a `SELECT` first: `SELECT e.id, e.dept_name, (SELECT d.name FROM departments d WHERE d.id = e.dept_id) AS new_name FROM employees e;` and eyeball the rows where the new value comes back `NULL`. 2. Decide explicitly what those rows should do — keep the old value, be excluded, or genuinely become `NULL`. 3. Encode that decision as `WHERE EXISTS`, `COALESCE`, or a deliberate no-op. 4. Compare the affected-row count with the count of rows you expected to match. The bug survives in the wild because the broken statement is shorter than the correct one and works perfectly on test data where every row happens to have a match.

  • Why prefer WHERE EXISTS over WHERE dept_id IN (SELECT id FROM departments) as the guard?
    EXISTS asks only whether a matching row was found, so it is unaffected by NULLs in the compared columns. IN drags three-valued logic in, and its negation NOT IN turns dangerous the moment the subquery yields a NULL. EXISTS also mirrors the SET subquery's correlation predicate exactly, which keeps the two copies in step.
  • What happens if the correlated subquery in SET returns more than one row for some target row?
    The statement fails at runtime with a cardinality error, and that is the desirable behaviour — silently choosing one source row would make the result non-deterministic. If the source legitimately has several matching rows, aggregate on purpose with MAX, MIN or SUM so the choice is written into the statement.
  • How do you choose between the EXISTS guard and the COALESCE form?
    Use EXISTS when unmatched rows should not be touched at all: the affected-row count then reports genuine matches, and triggers and change-tracking see only real changes. Use COALESCE when you want a single-pass statement and the source column cannot be NULL, since COALESCE cannot tell a missing source row from a source value that really is NULL.
  • How would you sanity-check such an update before running it?
    Run the same correlation as a SELECT with NOT EXISTS to list exactly the rows that have no source match, and confirm the count and their intended fate. Afterwards compare the statement's affected-row count with the number of rows you expected to match — a larger number means the guard is missing or wrong.

The subquery is a lookup clerk who always hands back an envelope — an empty envelope when the record is missing. Filing the envelope regardless is what erases the data; the WHERE clause is where you decide not to file empty ones.

saying these in an interview costs you the question

  • Expecting the SET subquery to skip rows it cannot match
  • Believing an empty scalar subquery leaves the column unchanged
  • Guarding with NOT IN against a nullable column
  • Assuming test data with full coverage proves the statement safe
  • Ignoring that duplicate source matches abort the statement

context