·7 min read
A field guide to NULL
NULL is not a value, it is the absence of one, and every SQL operator has its own opinion about what to do with an absence.
- sql
- bugs
- fundamentals
Every NULL bug I have shipped had the same shape. The query ran. It returned rows. The number was wrong, and it was wrong in the direction nobody checks. NULL is not a value. It is the absence of one, and every operator in SQL treats an absence differently.
Eight orders, nothing missing
reps: r1 Ana · r2 Ben · r3 Cy
COUNT(*)
8
COUNT(amount)
8
SUM(amount)
900
AVG(amount)
112.50
Eight rows, nothing missing
One cell goes NULL
Equals finds nothing
The NOT IN trap
The join that drops rows
Three truth values
Your turn
Three truth values, not two
The thing to internalise is that a comparison involving NULL is not false. It is UNKNOWN, a third truth value. WHERE keeps a row only when its predicate is TRUE, so UNKNOWN gets dropped like FALSE. That part feels harmless. The part that bites is NOT: NOT UNKNOWN is still UNKNOWN. You cannot flip your way out.
Everything above is computed on the page. The table is eight made-up orders. The results are not made up: a small evaluator implements the standard rules for comparisons, AND, OR, NOT, IN, aggregates, grouping, ordering and joins, and a test file checks it against twenty cases before I trust it in front of you.
8 to 7
COUNT(amount) after one NULL
COUNT(*) stays at 8
112.50 to 121.43
AVG(amount) after the same NULL
divides by the non-null count
0 rows
from NOT IN once the subquery has a NULL
with the right answer being one rep
The five NULL bugs you will ship this year
The average that drifts. AVG ignores NULL and divides by what is left. Row 4 loses its amount, the average rises, and no one asks why revenue per order improved. If a NULL means zero in your domain, say so with COALESCE(amount, 0). If it means unknown, leave it out and report the count next to the average.
The equals that finds nothing. WHERE status = NULL is syntactically fine and returns nothing, always. Same for != NULL. You need IS NULL and IS NOT NULL, or IS DISTINCT FROM when you want a null-safe comparison of two columns.
The NOT IN that empties the report. “Which reps have no orders” is a NOT IN (SELECT rep_id FROM orders) for most people. The day one order has a NULL rep_id, that list is empty, and it will stay empty until someone notices the report has been blank for a month. Use NOT EXISTS. It asks the question one row at a time and a NULL cannot poison it.
The join that loses rows. Inner joins are comparisons. A NULL key matches nothing, including another NULL, so the row silently drops out of the result. If the key can be NULL, decide whether you want those rows and write LEFT JOIN on purpose.
The count that disagrees with itself. COUNT(*) counts rows. COUNT(col) counts non-null values. COUNT(DISTINCT col) ignores NULL entirely, while SELECT DISTINCT col keeps exactly one NULL, and GROUP BY col gives NULL its own group. Three different answers to “how many regions”, all correct.
Where engines disagree
Almost none of this varies by database. The one place it does is sorting. Postgres, Oracle and Snowflake treat NULL as larger than every value, so it lands last in an ascending sort. MySQL, SQL Server, SQLite and BigQuery treat it as smaller, so it lands first. The same ORDER BY amount puts the missing rows at opposite ends of the page depending on where it runs. Write NULLS FIRST or NULLS LAST and stop depending on the default.
The two habits that prevent all of them
First: decide what NULL means, per column, and write it down in the model. “Unknown” and “not applicable” and “zero” are three different things and SQL will not tell them apart for you. Once the meaning is fixed, the operator choice follows. Zero gets a COALESCE at the staging layer. Unknown stays NULL and gets a count alongside every average.
Second: never write a negative membership test. NOT IN with a subquery and != against a nullable column are the two constructs I refuse in review. NOT EXISTS and IS DISTINCT FROM do the same job and cannot be poisoned. This is not style. It is the difference between a report that is wrong and one that is empty, and empty at least gets noticed.
What to do this week
- Grep your models for
NOT IN (SELECTand!=against nullable columns. Replace withNOT EXISTSandIS DISTINCT FROM. - Add
not_nulltests to every join key in dbt. A NULL key is a dropped row waiting to happen. - Put
COUNT(col)next to everyAVG(col)on any dashboard that matters. - Write
NULLS LASTorNULLS FIRSTin everyORDER BYon a nullable column. - Go back to the table above, toggle a cell, and watch which queries move. That is the whole guide.
One visual essay, every week.
No roundups, no sponsors. Unsubscribe in one click.