07 · Working with NULL¶
🎥 Video walkthrough¶
NULL represents "unknown" or "absent" — it is not the same as zero, an
empty string, or false. It has special comparison rules that trip up almost
everyone the first time.
NULL is not equal to anything, including itself¶
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
price REAL,
discontinued_reason TEXT
);
INSERT INTO books (title, price, discontinued_reason) VALUES
('Dune', 9.99, NULL),
('Old Title', NULL, 'out of print'),
('Another Old One', NULL, NULL);
SELECT * FROM books WHERE price = NULL; -- returns ZERO rows, always
SELECT * FROM books WHERE price != NULL; -- also returns ZERO rows, always
Both queries above are silently wrong but produce no error — = NULL and !=
NULL never match anything because comparing "unknown" to anything, even
NULL itself, yields "unknown" (which SQL treats as not-true). This is one
of the most common real-world SQL bugs.
IS NULL and IS NOT NULL — the correct check¶
id title price discontinued_reason
-- ---------------- ----- --------------------
2 Old Title NULL out of print
3 Another Old One NULL NULL
COALESCE — first non-NULL value¶
title status
---------------- --------------
Dune still in print
Old Title out of print
Another Old One still in print
COALESCE(a, b, c, ...) returns the first argument that isn't NULL — the
standard way to provide a fallback/default display value.
NULL in arithmetic and aggregates¶
Any arithmetic expression involving NULL produces NULL — it propagates
through calculations rather than being treated as zero.
COUNT(*) counts every row. COUNT(column) and aggregate functions like
AVG, SUM, MIN, MAX all silently skip NULL values rather than
erroring or counting them as zero — AVG(price) here is 9.99, not 3.33.
NULL in ORDER BY¶
In SQLite, NULL sorts first in ascending order (before any real number) and
last in descending order. Other databases vary on this default (Postgres also
puts NULL last on DESC, but treats it as largest by default on some
configurations) — always check your specific engine's docs, or force it
explicitly:
SELECT title, price FROM books ORDER BY price IS NULL, price;
-- non-NULL prices first (in ascending order), NULLs pushed to the end
NULLIF — turn a specific value into NULL¶
NULLIF(a, b) returns NULL if a equals b, otherwise returns a —
useful for turning sentinel values like 0 or '' into a proper NULL
before further processing (e.g. before passing into AVG, so a placeholder
zero doesn't wrongly drag down the average).
Cheat sheet¶
| Task | Correct approach |
|---|---|
| Check for NULL | WHERE col IS NULL (never = NULL) |
| Check for NOT NULL | WHERE col IS NOT NULL |
| Provide a default | COALESCE(col, 'default') |
| Turn a sentinel into NULL | NULLIF(col, sentinel_value) |
| Count non-NULL values | COUNT(col) (not COUNT(*)) |
| NULLs in aggregates | Silently skipped, not treated as 0 |
| NULLs in arithmetic | Any operation with NULL produces NULL |
How It Actually Works¶
NULL isn't a value stored in a column — in SQLite's record format, a NULL is
represented by a serial type of 0 in the row header, meaning zero payload
bytes are stored for that column at all. This is why NULLs are essentially
free storage-wise. The reason NULL = NULL evaluates to NULL (not true) is
that SQL's comparison operators implement three-valued logic: every
comparison against an unknown returns "unknown," not false — the engine
literally propagates a NULL marker through the expression tree rather than a
boolean. IS NULL / IS NOT NULL are special VDBE opcodes that check the
record header's serial type directly rather than performing a value
comparison, which is why they're the only reliable way to test for NULL.
This three-valued logic also silently affects WHERE, which only keeps rows
where the predicate evaluates to true (not just non-false) — a row where
the condition evaluates to NULL is filtered out exactly like a false one,
which is a common source of "missing rows" bugs when NULLs are involved in
NOT IN subqueries.
🔀 See this in another language¶
Exercise¶
Using the books table above: write a query using COALESCE that shows each
book's price, defaulting to 0.00 when the price is NULL. Then write a
query that counts how many books have a NULL price using IS NULL, and a
separate query using NULLIF that treats a price of exactly 0 the same as
NULL when computing the average price.