Concept · Writing queries
NULL and three-valued logic
NULL means unknown, so comparisons return unknown — and NOT IN with a NULL returns nothing.
In SQL, NULL doesn't mean zero or empty. It means unknown. Any comparison with an unknown value is itself unknown, so SQL logic has three values: TRUE, FALSE and UNKNOWN.
SELECT NULL = NULL; -- NULL (unknown), not true
SELECT NULL <> 1; -- NULL
SELECT NULL AND FALSE; -- false: false whatever the unknown is
SELECT NULL OR TRUE; -- trueWHERE, HAVING, ON and CHECK constraints don't treat unknown the same way. The first three keep a row only when the condition is true, so UNKNOWN drops the row just like FALSE. A CHECK constraint only rejects FALSE, so UNKNOWN passes. That's why CHECK (price > 0) still allows a NULL price.
The traps
= NULLnever matches. UseIS NULL/IS NOT NULL. To compare two columns where NULL should equal NULL, useIS NOT DISTINCT FROM.NOT INwith a NULL in the list returns no rows.x NOT IN (1, NULL)meansx <> 1 AND x <> NULL, and the second part is always unknown. Subqueries hit this all the time:
-- Returns nothing if any order has a NULL customer_id
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- Safe version
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);- Negating a filter doesn't give you "everything else."
WHERE status <> 'done'skips rows wherestatusis NULL. Those rows are in neither half. - Aggregates skip NULLs.
count(col)counts non-NULL values whilecount(*)counts rows.avg(col)divides by the non-NULL count.sumover no rows (or only NULLs) returns NULL, not 0, so wrap it inCOALESCE(sum(x), 0)when you need a number. - Outer joins create NULLs. In a
LEFT JOIN, aWHEREcondition on a right-hand column removes exactly the unmatched rows you joined to keep. See logical query order.
Designing with NULL
Make columns NOT NULL unless "unknown" is a real state of the data. A nullable boolean has three states, and every query that reads it has to handle all three. Where NULL is legitimate, such as an optional end date, say so in the schema and handle it on purpose with COALESCE or IS NULL. Don't find out from a query that quietly returned too few rows.