NULL and three-valued logic
Why SQL has three truth values, where NULL leaks into answers you thought were safe, and how to compare columns that may be missing.
After this lesson you can
- Evaluate AND, OR and NOT when one side is unknown
- Explain why NOT IN with a NULL in the list returns nothing
- Compare two nullable columns correctly with IS DISTINCT FROM
- Choose between COALESCE and an explicit IS NULL test
NULL is not a value. It is a marker that says "there is a value here and
we do not know it". Everything else in this lesson follows from taking
that literally.
Three truth values
A comparison with an unknown is itself unknown. So SQL logic has three
results: true, false and unknown (which SQL shows as NULL).
The connectives behave the way you would reason about a fact you cannot
see:
unknown AND falseis false — one false side settles it.unknown AND trueis unknown — it depends on the side you cannot see.unknown OR trueis true;NOT unknownis still unknown.
Arithmetic and string concatenation with NULL give NULL as well.
SELECT NULL = NULL AS null_eq_null, CAST(NULL AS boolean) AND FALSE AS unknown_and_false, CAST(NULL AS boolean) AND TRUE AS unknown_and_true, CAST(NULL AS boolean) OR TRUE AS unknown_or_true, NOT CAST(NULL AS boolean) AS not_unknown, 1 + NULL AS arithmetic, 'abc' || NULL AS concat;Three clauses act on these results, and they do not all treat unknown the
same way. WHERE, HAVING and ON keep a row only when the condition is
true; unknown is dropped exactly like false. A CHECK constraint does the
opposite and rejects only false, which the constraints lesson comes back
to.
NOT IN and the NULL in the list
x NOT IN (a, b) means x <> a AND x <> b. If one of the values in the
list is NULL, one of those comparisons is unknown, and the whole AND
can never be true. The query returns no rows at all — no error, no
warning.
CREATE TABLE customers ( id int PRIMARY KEY, name text NOT NULL, country text);INSERT INTO customers VALUES (1, 'Nino', 'GE'), (2, 'Hans', 'DE'), (3, 'Piotr', 'PL'), (4, 'Ghost', NULL);SELECT (SELECT COUNT(*) FROM customers WHERE country NOT IN ('DE')) AS not_in_de, (SELECT COUNT(*) FROM customers WHERE country NOT IN ('DE', NULL)) AS not_in_de_or_null, (SELECT COUNT(DISTINCT country) FROM customers) AS count_distinct, (SELECT COUNT(*) FROM (SELECT DISTINCT country FROM customers) AS d) AS distinct_rows;The same example shows the quieter half: a row whose own country is
NULL never passes NOT IN ('DE') either. And it shows that grouping is
different from comparing: DISTINCT and GROUP BY put all the NULLs in
one group, while COUNT(DISTINCT country) skips them altogether.
Comparing two columns that can both be NULL
"Has the bonus changed from its target?" sounds like bonus <> target.
It is not: when either side is NULL the answer is unknown, and the row
disappears. Standard SQL has a comparison that treats two NULLs as equal
and a NULL against a value as different:
WHERE bonus IS DISTINCT FROM target
PostgreSQL and SQL Server 2022 support it; MySQL spells the opposite test
NOT (bonus <=> target). Where neither is available, write it out:
WHERE bonus <> target
OR (bonus IS NULL AND target IS NOT NULL)
OR (bonus IS NOT NULL AND target IS NULL)
CREATE TABLE staff ( id int PRIMARY KEY, name text NOT NULL, bonus int, target int);INSERT INTO staff VALUES (1, 'Nino', 100, 100), (2, 'Ana', NULL, NULL), (3, 'Luka', NULL, 50), (4, 'Mari', 0, NULL), (5, 'Giorgi', 70, 80);SELECT name, bonus <> target AS not_equal, bonus IS DISTINCT FROM target AS is_distinct, COALESCE(bonus, 0) <> COALESCE(target, 0) AS coalescedFROM staffORDER BY id;COALESCE(a, b, ...) returns its first non-null argument, and it is the
right tool when a missing value really does mean a default. It is the
wrong tool for comparisons: COALESCE(bonus, 0) <> COALESCE(target, 0)
decides that "no bonus" equals "a bonus of zero", which is a business
decision hidden inside a filter — Mari's row in the example above.
Common mistakes
NOT INagainst a subquery that can returnNULL. OneNULLin the subquery's column and the result is empty. Filter theNULLs out of the subquery, or useNOT EXISTS, which the subqueries lesson shows is immune to this.<>on a nullable column.WHERE status <> 'closed'silently drops every row whose status isNULL; addOR status IS NULLif those rows belong in the answer.- Replacing
NULLwith a sentinel to make a comparison work.COALESCE(x, 0)merges "unknown" with a real zero, as Mari's row shows. - Expecting
COUNT(DISTINCT col)to count theNULLgroup. It does not;SELECT DISTINCTdoes.
Exercise · Pro
Bonuses that do not match their target
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade