SQL NULL in WHERE: Why = NULL Matches Nothing
7 min readBytePatterns
Why WHERE col = NULL returns no rows: SQL's three-valued logic, IS NULL, how NULL slips through AND, OR and NOT, and why COUNT and AVG skip it, run in SQLite.
WHERE looks like the simplest clause in SQL: a yes-or-no test per row, keep the yeses. Then a column contains NULL, and queries start losing rows without an error in sight. WHERE mites = NULL returns nothing, even when the column is full of NULLs. WHERE mites <> 12 quietly drops them too. The reason is one rule that most bugs trace back to: SQL tests are not true or false, they are true, false or unknown, and WHERE keeps only true.
The problem it solves
NULL means "no value known": a hive that was never inspected, a customer with no phone number, an order not yet shipped. The mistakes it causes look like this:
- Asking for missing values with
=.col = NULLnever matches, so a report of "hives with no mite count" comes back empty. - Losing rows through negation.
NOT (mites > 40)andmites <> 12both skip the uninspected hive, although it is certainly not over 40 and certainly not recorded as 12. - Averages that ignore rows.
AVG(mites)divides by the rows that have a value, not by all rows. NOT INagainst a set containingNULL, which returns no rows at all, as the subqueries article shows.
The intuition
Read NULL as unknown, and every rule follows from asking "could this be decided?":
- Any comparison with unknown is unknown. Is an unknown count greater than 40? Unknown. Equal to another unknown? Also unknown, which is why
NULL = NULLis not true. ANDis false if either side is false, true if both are true, and unknown otherwise.unknown AND falseis false, because the answer does not depend on the unknown.ORis true if either side is true, false if both are false, unknown otherwise.NOT unknownis unknown. Negating a question you cannot answer does not answer it.WHEREkeeps a row only when the result is true. Unknown is treated like false for filtering, but it is not false:NOTdoes not flip it into true.
The fix is to ask the question you mean. IS NULL and IS NOT NULL are always true or false. For "different from 12, counting missing as different", say so: mites <> 12 OR mites IS NULL, or use a null-safe comparison.
Watch it run
The animation filters the lesson's apiary with mites > 40 AND queen_seen = 0. WHERE runs a yes-or-no test on every candidate row and keeps only the passes. Willow has 12 mites and the threshold is 40, so it fails the first test. Chalk clears 40, but its queen was sighted, so the AND fails anyway. Beacon has three mites and its card stays in the drawer. Long Mead has 51 mites and no queen sighted: both halves are true. One row survives; sixty hives, one card pulled, decided before anyone walks into the field. Then the odd one out: Hollow was never inspected, so its mite count is NULL. mites = NULL is never true, because NULL means unknown and unknown equals nothing at all; it matches zero rows. WHERE mites IS NULL is the question you actually meant to ask. The last frame is the lesson's other rule: filter here, not in Python, because rows you drop later were still read, serialised and shipped.
WHERE and Filtering
Step 1 of 10
WHERE runs a yes-or-no test on every candidate row and keeps only the passes.
The same interactive animation as the lesson — step through it with the controls.
The code
Every query below runs in Python's sqlite3 module (SQLite 3.37). The lesson's table plus Hollow, and the traps one by one:
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE hive (name TEXT, mites INTEGER, queen_seen INTEGER);
INSERT INTO hive VALUES ('Willow', 12, 1), ('Chalk', 47, 1), ('Beacon', 3, 0),
('Long Mead', 51, 0), ('Hollow', NULL, 0);
""")
def names(where):
return [r[0] for r in db.execute("SELECT name FROM hive WHERE %s ORDER BY rowid" % where)]
print(names("mites > 40 AND queen_seen = 0")) # ['Long Mead']
print(names("mites = NULL")) # []
print(names("mites IS NULL")) # ['Hollow']
print(names("mites <> 12")) # ['Chalk', 'Beacon', 'Long Mead']
print(names("NOT (mites > 40)")) # ['Willow', 'Beacon']
print(names("mites > 40 OR queen_seen = 0")) # ['Chalk', 'Beacon', 'Long Mead', 'Hollow']
print(names("mites IS NOT 12")) # ['Chalk', 'Beacon', 'Long Mead', 'Hollow']
Hollow has queen_seen = 0, yet the first query drops it: unknown AND true is unknown. The OR query keeps it: unknown OR true is true. IS NOT is SQLite's null-safe "different from". The truth table and the aggregates, straight from the engine, where None is NULL, 0 is false and 1 is true:
print(db.execute("SELECT NULL = NULL, NULL AND 0, NULL AND 1, NULL OR 1, NULL OR 0, NOT NULL").fetchone())
# (None, 0, None, 1, None, None)
print(db.execute("SELECT COUNT(*), COUNT(mites), AVG(mites), AVG(COALESCE(mites, 0)) FROM hive").fetchone())
# (5, 4, 28.25, 22.6)
COUNT(*) counts rows, COUNT(mites) counts values, and AVG divides 113 by 4, not 5. COALESCE turns the unknown into a zero, which is a decision about your data, not a fix.
Checked on 300 seeded random tables with NULLs in two columns and 3,000 random WHERE clauses, nested up to three levels of AND, OR and NOT over comparisons and IS NULL tests. A Python evaluator of three-valued logic, with None as unknown, must pick exactly the rows SQLite returns:
import random
def and3(a, b):
if a is False or b is False:
return False
return None if a is None or b is None else True
def or3(a, b):
if a is True or b is True:
return True
return None if a is None or b is None else False
def not3(a):
return None if a is None else not a
OPS = {"=": lambda x, y: x == y, "<>": lambda x, y: x != y,
"<": lambda x, y: x < y, ">": lambda x, y: x > y}
def predicate(depth):
"""A random WHERE clause, as SQL text and as a Python three-valued function."""
kind = random.choice(["cmp", "null"] if depth == 0 else ["cmp", "null", "and", "or", "not"])
if kind == "cmp":
col, op, v = random.choice("ab"), random.choice(list(OPS)), random.randint(0, 3)
f = lambda r: None if r[col] is None else OPS[op](r[col], v) # NULL compares unknown
return "%s %s %d" % (col, op, v), f
if kind == "null":
col, neg = random.choice("ab"), random.random() < 0.5
return "%s IS %sNULL" % (col, "NOT " if neg else ""), lambda r: (r[col] is None) != neg
if kind == "not":
s, f = predicate(depth - 1)
return "NOT (%s)" % s, lambda r: not3(f(r))
(s1, f1), (s2, f2) = predicate(depth - 1), predicate(depth - 1)
comb = and3 if kind == "and" else or3
return "(%s) %s (%s)" % (s1, kind.upper(), s2), lambda r: comb(f1(r), f2(r))
random.seed(29)
ok = True
for _ in range(300):
t = sqlite3.connect(":memory:")
t.execute("CREATE TABLE t (id INTEGER, a INTEGER, b INTEGER)")
rows = [{"id": i, "a": random.choice([None, 0, 1, 2, 3]), "b": random.choice([None, 0, 1, 2, 3])}
for i in range(random.randint(0, 25))]
t.executemany("INSERT INTO t VALUES (:id, :a, :b)", rows)
for _ in range(10):
sql, f = predicate(random.randint(0, 3))
got = [r[0] for r in t.execute("SELECT id FROM t WHERE %s ORDER BY id" % sql)]
ok &= got == [r["id"] for r in rows if f(r) is True] # WHERE keeps only TRUE
print(ok) # True
The complexity
- Evaluation: one pass over the candidate rows,
O(n)without an index;NULLhandling adds nothing measurable. - Indexes: an equality or range condition on an indexed column becomes a search instead of a scan, as in SQL indexes. Whether
IS NULLcan use an index depends on the engine and on whether the index storesNULLs. - Network: filtering in the database ships only matching rows. Filtering in the application ships all of them first.
Where it goes wrong
= NULLand<> NULL. Both are always unknown. UseIS NULLandIS NOT NULL.- Negations that should include missing values. Add
OR col IS NULLexplicitly when "not 12" should include "unknown". - Joins on nullable keys.
NULLnever equalsNULL, so rows with a missing key never join, as the joins article shows. COALESCEeverywhere. Replacing unknown with zero changes averages and sums; do it only when zero is truly the meaning.
As of September 2026, the standard null-safe comparison is IS [NOT] DISTINCT FROM, supported by PostgreSQL and recent SQLite versions; SQLite also accepts IS and IS NOT between any values, and MySQL spells null-safe equality <=>.
When it shows up in interviews
As a trick question ("why does this query return nothing?"), as the reason a NOT IN returns an empty set, and inside every "find customers without orders" problem, where the answer is a left join with WHERE o.id IS NULL. It also sits under GROUP BY, because aggregates skip NULLs. The SQL cheat sheet collects the NULL rules next to the interview queries that trip over them.
How to say it in an interview
"SQL uses three-valued logic: a comparison with NULL is unknown, not false, and WHERE keeps only rows where the condition is true. So col = NULL never matches; I'd write col IS NULL. Negation doesn't help, because NOT unknown is still unknown, so if missing values should count as 'not 12', I add OR col IS NULL or use a null-safe comparison. I also watch aggregates: COUNT(col) and AVG(col) skip NULLs, while COUNT(*) counts rows. And I filter in the database, so only matching rows cross the network."