open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 01 · 03

NULL and three-valued logic

NULL means 'unknown', not 'empty'. That turns SQL's logic three-valued (TRUE/FALSE/UNKNOWN) and produces the silent NOT IN bug that returns zero rows the day one NULL appears.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A fraud filter ran clean for a year: WHERE user_id NOT IN (SELECT user_id FROM blocked_users). One night it returned zero flagged accounts — not a few, zero — and the on-call assumed the fraud ring had gone quiet. It hadn’t. Someone had inserted one blocked_users row with a NULL user_id, and that single NULL silently turned the entire NOT IN into “match nothing”. No error, no warning. This is the most expensive NULL bug in SQL, and it follows directly from one fact: in SQL, NULL means unknown.

NULL is “unknown”, not “empty” or “zero”

The single idea everything else hangs on: NULL is not a value, it’s the absence of a known value — “we don’t know”. It is not zero, not an empty string, not false. Two NULLs are not equal to each other, because “unknown” can’t be confirmed equal to “unknown”.

That means comparisons against NULL don’t return TRUE or FALSE — they return a third truth value, UNKNOWN. 5 = NULL is UNKNOWN. NULL = NULL is UNKNOWN. NULL <> 3 is UNKNOWN. SQL is therefore a three-valued logic: TRUE, FALSE, and UNKNOWN.

The hard rule that follows: WHERE keeps a row only when its predicate evaluates to TRUE. UNKNOWN is not TRUE, so a row whose predicate is UNKNOWN is dropped — just like FALSE. That symmetry is the source of nearly every NULL surprise.

-- users(id, email, deleted_at)  -- deleted_at is NULL for active users
SELECT count(*) FROM users WHERE deleted_at = NULL;   -- always 0 rows!
SELECT count(*) FROM users WHERE deleted_at IS NULL;  -- the correct test

= NULL is never the test you want. To ask “is this unknown?” you must use the dedicated IS NULL / IS NOT NULL operators, which return real booleans.

The three-valued truth tables

Each connective has to define what it does when an operand is UNKNOWN. The key entries:

The two short-circuit rows matter in practice: TRUE OR <anything> is TRUE and FALSE AND <anything> is FALSE, even when “anything” is UNKNOWN — because the result is already determined. Every other combination touching UNKNOWN yields UNKNOWN.

The NOT IN trap, decoded

Now the opening incident. x NOT IN (a, b, c) is defined as x <> a AND x <> b AND x <> c. Suppose the list is (1, 2, NULL) and x = 5:

5 <> 1  → TRUE
5 <> 2  → TRUE
5 <> NULL → UNKNOWN      -- because comparing to NULL is always UNKNOWN
TRUE AND TRUE AND UNKNOWN → UNKNOWN

The whole expression collapses to UNKNOWN, so the row is dropped. With even one NULL in the NOT IN list, no row can ever be TRUE — the query returns zero rows, silently, for every value. That’s exactly how a single NULL in blocked_users zeroed out the fraud filter.

The fixes, in order of preference:

  • Use NOT EXISTS instead of NOT IN for subqueries. NOT EXISTS is NULL-safe (it checks row existence, not value equality) and usually plans better as an anti-join:
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM blocked_users b WHERE b.user_id = u.id
);
  • Or exclude NULLs from the subquery: ... NOT IN (SELECT user_id FROM blocked_users WHERE user_id IS NOT NULL).
  • Better still, make the column NOT NULL so the bug is impossible by construction.

All three fixes address the same root: once a NULL can reach the comparison, the logic collapses. The strongest fix (NOT NULL constraint) eliminates the possibility at the schema level; NOT EXISTS is the safest drop-in replacement for existing queries without a schema change.

IS DISTINCT FROM, and the NULL-aware toolkit

Sometimes you genuinely want “are these two values different, treating NULL as a normal comparable value?” Plain <> can’t, because it goes UNKNOWN on NULL. Postgres gives you IS DISTINCT FROM: it returns a real boolean, treating two NULLs as not distinct (equal) and a NULL-vs-value as distinct.

-- detect a changed column even when either side may be NULL:
SELECT * FROM staging s JOIN target t USING (id)
WHERE s.email IS DISTINCT FROM t.email;   -- TRUE/FALSE, never UNKNOWN

The everyday helpers:

  • COALESCE(a, b, …) returns the first non-NULL argument — your default-value tool: COALESCE(nickname, email, 'anon').
  • NULLIF(a, b) returns NULL when a = b, else a — handy to dodge divide-by-zero: total / NULLIF(qty, 0).

And two aggregate facts that trip people: aggregates skip NULLs. COUNT(col) counts only non-NULL values of col, while COUNT(*) counts rows regardless. AVG(col) averages over non-NULL values only — so an AVG can differ from SUM/COUNT(*) when NULLs are present.

Edge cases

NULL and UNIQUE disagree with intuition too. A UNIQUE constraint treats NULLs as distinct, so by default a unique column allows many NULL rows — they don’t violate uniqueness because no two NULLs are “equal”. If you need at most one NULL, Postgres 15+ offers UNIQUE NULLS NOT DISTINCT, which treats NULLs as equal for the constraint. Same root cause: NULL = NULL is UNKNOWN, so the default uniqueness check never fires on NULLs.

Quiz

Why does WHERE deleted_at = NULL return zero rows even when many rows have a NULL deleted_at?

Quiz

A NOT IN (subquery) suddenly returns zero rows after a deploy. Most likely cause and best fix?

Complete the analogy

Fill in the blank: in SQL a NULL means the value is _______ — not zero and not empty — which is why x = NULL evaluates to UNKNOWN rather than TRUE or FALSE.

Recall before you leave
  1. 01
    Why is = NULL never the right test, and what is the third truth value involved?
  2. 02
    Explain precisely why NOT IN with a NULL in the list returns zero rows.
  3. 03
    Contrast COUNT(col) with COUNT(*), and state what IS DISTINCT FROM is for.
Recap

NULL is the absence of a known value — “unknown” — not zero and not empty. Because you can’t confirm “unknown = anything”, comparisons against NULL produce a third truth value, UNKNOWN, making SQL a three-valued logic. WHERE (and HAVING, and JOIN conditions) keep a row only when the predicate is TRUE, so UNKNOWN rows are dropped just like FALSE — which is why = NULL always yields nothing and you must use IS NULL. The marquee failure mode is NOT IN with a NULL in its list: it collapses to UNKNOWN for every row and returns zero results silently, so prefer the NULL-safe NOT EXISTS (or filter NULLs, or make the column NOT NULL). Round out the toolkit with COALESCE for defaults, NULLIF to dodge divide-by-zero, and IS DISTINCT FROM for NULL-aware inequality — and remember aggregates skip NULLs, so COUNT(col) and COUNT(*) differ. Now when you see a filter that should match rows but returns nothing, ask: is there a NULL somewhere in the comparison path? That single question catches = NULL, NOT IN (subquery with NULLs), and <> on a nullable column — all the same root cause.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.