skip to content

In SQL, how do you compare two nullable columns so that two NULLs count as equal?

level: middleimportance: should knowfreq 42%

answer

  1. Equality cannot help you here
  2. You need a two-valued comparison
  3. The standard names it after being distinct
  4. Some engines spell it with an operator instead
  5. Portable fallback: an explicit both-NULL branch

basics

~20 s

Use the standard NULL-safe comparison IS NOT DISTINCT FROM, which returns TRUE when both sides are NULL and never yields UNKNOWN. Where an engine lacks it, write a = b OR (a IS NULL AND b IS NULL).

solid answer

~50 s

`=` is unusable for this: if either side is NULL the comparison is UNKNOWN, so two missing values never match. SQL provides a two-valued alternative — `a IS NOT DISTINCT FROM b` is TRUE when the values are equal *or* both absent, and FALSE otherwise; its complement `a IS DISTINCT FROM b` is TRUE when exactly one side is NULL or the values differ. Neither ever returns UNKNOWN, so they behave like ordinary booleans. Engine support varies: PostgreSQL implements the standard spelling, MySQL offers the `<=>` NULL-safe equality operator, and SQLite's `IS` / `IS NOT` operators compare NULLs as equal. The always-portable rewrite is `a = b OR (a IS NULL AND b IS NULL)`. Beware the sentinel trick `COALESCE(a, -1) = COALESCE(b, -1)`: it is only correct if the sentinel can never occur in the data.

code

sql · 9 lines
sql
-- Misses rows changing to or from NULL
SELECT s.id
FROM staging s JOIN target t ON t.id = s.id
WHERE s.email <> t.email;

-- Catches every genuine change, NULLs included
SELECT s.id
FROM staging s JOIN target t ON t.id = s.id
WHERE s.email IS DISTINCT FROM t.email;

go deeper

for a junior

Recall that = cannot match two NULLs and that SQL has a NULL-safe comparison for exactly that case. Knowing the name IS NOT DISTINCT FROM is enough at this stage.

for a middle

Explain the truth behaviour — TRUE when both are NULL, FALSE when exactly one is — and stress that these predicates are two-valued. Be able to write the portable OR (a IS NULL AND b IS NULL) rewrite from memory.

for a senior

Show where this bites in production: change-detection and upsert diffs on nullable columns that silently under-report, plus the sargability cost of the COALESCE sentinel workaround people reach for instead.

for a principal

Weigh whether the columns should be nullable at all in a pipeline that diffs them, and whether a portability constraint across engines justifies standardising on the verbose rewrite in shared code.

## The problem A change-detection query asks "did this column's value change?" over a nullable column: ```sql SELECT s.id FROM staging s JOIN target t ON t.id = s.id WHERE s.email <> t.email; -- misses rows where either email is NULL ``` If one side is NULL the comparison is UNKNOWN and `WHERE` discards the row, so a value that changed from NULL to `'[email protected]'` — a genuine change — is reported as unchanged. Worse, the reverse case is also invisible: a value that changed from `'[email protected]'` to NULL does not show up either. Any code that diffs, deduplicates, or matches on nullable columns runs into this. ## The standard operator SQL defines a NULL-safe comparison built on the notion of two values being *distinct*: ```sql a IS NOT DISTINCT FROM b -- TRUE when equal, or when both are NULL a IS DISTINCT FROM b -- TRUE when they differ, or when exactly one is NULL ``` The defining property is that these predicates are **two-valued**: they always return TRUE or FALSE and never UNKNOWN. Full truth behaviour: | a | b | a = b | a IS NOT DISTINCT FROM b | |---|---|---|---| | 1 | 1 | TRUE | TRUE | | 1 | 2 | FALSE | FALSE | | 1 | NULL | UNKNOWN | FALSE | | NULL | NULL | UNKNOWN | TRUE | So the change-detection query becomes correct simply by swapping the operator: ```sql WHERE s.email IS DISTINCT FROM t.email ``` and now reports exactly the rows whose value differs, including transitions into and out of NULL. ## Portability This is one of the places where engines genuinely diverge, so state your assumption. PostgreSQL implements the standard `IS [NOT] DISTINCT FROM` spelling. MySQL provides the NULL-safe equality operator `<=>`, which is equivalent to `IS NOT DISTINCT FROM`. SQLite's `IS` and `IS NOT` operators compare like `=` but treat two NULLs as equal. Other engines may or may not have the standard form, so check the documentation for the one you target. The rewrite that runs everywhere is a plain disjunction: ```sql WHERE (a = b) OR (a IS NULL AND b IS NULL) -- NULL-safe equality WHERE (a <> b) OR (a IS NULL) <> (b IS NULL) -- avoid this shape; see below ``` The inequality direction is easier to write as the explicit three-branch form: ```sql WHERE (a <> b) OR (a IS NULL AND b IS NOT NULL) OR (a IS NOT NULL AND b IS NULL) ``` Verbose, but unambiguous and portable. Each branch supplies a decisive TRUE or FALSE, so no UNKNOWN escapes to the top of the condition. ## The sentinel shortcut and its trap A popular shortcut replaces the NULLs with a value that "can never occur": ```sql WHERE COALESCE(a, '~none~') <> COALESCE(b, '~none~') ``` This works only under an assumption you must be able to defend: that the sentinel truly cannot appear in the data. The day a row legitimately contains `'~none~'`, the comparison quietly treats it as equal to a NULL and the bug is very hard to see. For numeric columns the usual `-1` sentinel is even riskier. It also has a secondary cost — wrapping a column in a function makes the predicate non-sargable, so an index on that column can no longer be used for a direct lookup. When you do need a sentinel (for example on an engine without the standard operator, inside a grouping key), pick a value that is structurally impossible for the domain and write a comment saying why. ## Related shapes - **Negation.** `NOT (a IS DISTINCT FROM b)` is exactly `a IS NOT DISTINCT FROM b`. Because these predicates are two-valued, `NOT` behaves normally around them — one of the few places NULLable columns keep boolean intuition. - **Row-wise comparison.** Some engines allow the operator on row values, comparing several columns at once. Support is uneven; the portable fallback is one NULL-safe comparison per column, ANDed together. - **Grouping versus comparing.** SQL is deliberately inconsistent here: `=` says two NULLs are not known to be equal, but `GROUP BY` and `DISTINCT` treat them as alike and collapse them. `IS NOT DISTINCT FROM` is the comparison operator that matches the grouping behaviour, which is a neat way to describe it in an interview. ## How to answer Name the operator, state the property that makes it work (two-valued, never UNKNOWN), give the portable `OR (a IS NULL AND b IS NULL)` rewrite, and flag the sentinel trick as conditional on an assumption about the data.

  • Can IS DISTINCT FROM ever return UNKNOWN?
    No, and that is the whole point. Both `IS DISTINCT FROM` and `IS NOT DISTINCT FROM` are two-valued: every combination of inputs, NULLs included, yields TRUE or FALSE. That means `WHERE` never silently drops a row on them, and `NOT` around them behaves the way boolean intuition expects.
  • Is COALESCE(a, -1) = COALESCE(b, -1) a safe substitute?
    Only if -1 can never legitimately appear in the data. The moment a real row holds the sentinel, it compares equal to a NULL and the query is wrong in a way that is very hard to spot. It also wraps the column in a function, which makes the predicate non-sargable and blocks a straightforward index lookup.
  • How would you write a NULL-safe comparison across several columns at once?
    Portably, one NULL-safe comparison per column joined with `AND`: `a1 IS NOT DISTINCT FROM b1 AND a2 IS NOT DISTINCT FROM b2`. Some engines accept the operator on row values, comparing the whole tuple in one predicate, but support is uneven — check the engine before relying on the compact form.

saying these in an interview costs you the question

  • Using <> to detect changes in a nullable column
  • Believing = treats two NULLs as equal
  • Assuming IS NOT DISTINCT FROM exists on every engine
  • Reaching for a COALESCE sentinel without proving it cannot occur in data
  • Claiming NOT around IS DISTINCT FROM leaves UNKNOWN behind

context