NULL and three-valued logic
Why NULL = NULL is not true, how NOT IN returns nothing, and what aggregates, sorting and UNIQUE do with missing values.
After this lesson you can
- Evaluate AND, OR, NOT and comparisons when one side is NULL
- Replace NOT IN with NOT EXISTS and explain why it matters
- Say what count, sum and avg do with NULL and with zero rows
- Choose IS DISTINCT FROM, COALESCE or NULLIF for a given job
NULL means "unknown", not "empty" and not zero. Any comparison with an
unknown value is itself unknown, so SQL logic has three values: true,
false and NULL. A WHERE clause keeps a row only when the condition is
true; a NULL condition drops the row exactly like false does.
The truth table you need
NULL AND false is false (whatever the unknown is, the result is false),
but NULL AND true is unknown. Likewise NULL OR true is true, but
NULL OR false is unknown. NOT NULL is still unknown.
SELECT NULL = NULL AS eq, NULL IS NULL AS is_null, NULL IS DISTINCT FROM NULL AS distinct_nulls, 1 IS DISTINCT FROM NULL AS distinct_one, (NULL AND false) AS and_false, (NULL AND true) AS and_true, (NULL OR true) AS or_true, 'a' || NULL AS concat_op, concat('a', NULL, 'b') AS concat_fn, 2 NOT IN (1, NULL) AS not_in;Three tools handle NULL deliberately:
IS NULL/IS NOT NULL— the only way to test for it.a IS DISTINCT FROM b— a comparison that treats two NULLs as equal and never returns NULL. Use it in triggers and change detection.COALESCE(a, b, ...)returns the first non-NULL argument;NULLIF(a, b)returns NULL whena = b, the classic guard against division by zero:x / NULLIF(y, 0).
The || operator propagates NULL, so 'a' || NULL is NULL;
concat() skips NULL arguments instead.
The NOT IN trap
x NOT IN (1, NULL) means x <> 1 AND x <> NULL. The second comparison
is unknown, so the whole condition can never be true. One NULL in the
subquery and NOT IN returns no rows at all. NOT EXISTS compares row by
row and is unaffected.
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL);CREATE TABLE orders (id int PRIMARY KEY, customer_id int);INSERT INTO customers VALUES (1, 'Nino'), (2, 'Ana'), (3, 'Luka');-- order 11 was imported without a customerINSERT INTO orders VALUES (10, 1), (11, NULL);SELECT 'NOT IN' AS method, count(*) AS customers_without_ordersFROM customersWHERE id NOT IN (SELECT customer_id FROM orders)UNION ALLSELECT 'NOT EXISTS', count(*)FROM customers cWHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);Aggregates, sorting and constraints
Aggregates skip NULL inputs. count(*) counts rows, count(col) counts
non-NULL values, and avg(col) divides by the non-NULL count, not the row
count. Over zero rows, count returns 0 but sum, avg, min and max
return NULL, so a "total" for an empty period needs COALESCE(sum(x), 0).
When sorting, PostgreSQL treats NULL as larger than every value: last in
ascending order, first in descending order. NULLS FIRST / NULLS LAST
overrides it.
CREATE TABLE readings (id int PRIMARY KEY, v int);INSERT INTO readings VALUES (1, 10), (2, NULL), (3, 20);SELECT count(*) AS rows_counted, count(v) AS values_counted, sum(v) AS total, avg(v) AS average, (SELECT sum(v) FROM readings WHERE id > 99) AS sum_of_nothing, (SELECT count(v) FROM readings WHERE id > 99) AS count_of_nothing, array_agg(v ORDER BY v)::text AS ascending, array_agg(v ORDER BY v DESC)::text AS descendingFROM readings;A plain UNIQUE constraint allows any number of NULLs, because no two
unknowns are known to be equal. Since PostgreSQL 15 you can declare
UNIQUE NULLS NOT DISTINCT to allow only one; the next lesson runs both.
Common mistakes
WHERE status <> 'closed'to find "not closed" rows: rows with a NULL status are silently excluded. Writestatus IS DISTINCT FROM 'closed'if they should be included.NOT IN (SELECT ...)against a nullable column. One NULL and the query returns nothing, as the example shows; useNOT EXISTS.- Reporting
sum(amount)for a period with no rows and printing NULL instead of 0. - Computing an average as
sum(x) / count(*)whenxcan be NULL; it disagrees withavg(x), which divides bycount(x).
What interviewers ask
- What does
SELECT NULL = NULLreturn, and how do you compare two nullable columns? NULL, not true; useIS NOT DISTINCT FROM. - Why might
NOT INreturn zero rows when you expect some? A NULL in the list makes every comparison unknown;NOT EXISTSor a left anti-join avoids it.
Try it yourself
2 visible tests · 2 hidden testsTable tickets(id, title, status, assignee). Both status and
assignee are nullable: NULL means "not set yet". Return id and
title of every ticket that is not closed (its status is not
'closed'; a NULL status counts as not closed) and not assigned to
'nino' (a NULL assignee counts as not assigned to her). Values are
compared exactly as stored, so 'Nino' is a different assignee.
Order by id.
CREATE TABLE tickets (id int PRIMARY KEY, title text NOT NULL, status text, assignee text);INSERT INTO tickets VALUES (1, 'Login fails', 'open', 'nino'), (2, 'Typo', 'closed', 'ana'), (3, 'Slow report', NULL, 'ana'), (4, 'Crash on save', 'in_progress', NULL), (5, 'Old bug', 'closed', NULL);Sign up to run the tests, check the hidden ones and save your progress. Sign up