Row Functions
NULL and Three-Valued Logic
Why `NULL = NULL` is `UNKNOWN`, the truth tables behind every NULL bug, and the four operators (`COALESCE`, `NULLIF`, `IS DISTINCT FROM`, `IS NULL`) that turn three-valued logic from a footgun into a tool.
Suggest an edit1. NULL and Three-Valued Logic
The Hook
A "users with no last_login" query for a churn-warning dashboard:
SELECT id, email FROM users WHERE last_login != CURRENT_DATE - INTERVAL '90 days';The query is supposed to find users whose last_login is anything other than "exactly 90 days ago" — i.e., everybody whose last login wasn't on that one day. Most users (3 million out of 3.1 million) should match.
The query returns 100,000 rows. Most users are missing.
The bug is NULL. last_login is nullable; users who never logged in have NULL. NULL != CURRENT_DATE - 90 is UNKNOWN, not TRUE. WHERE drops UNKNOWN rows. So the 3,000,000 users with NULL last_login all silently disappear — exactly the people the churn dashboard most needs to surface.
Every working SQL engineer has written this bug at least once. The chapter on Filtering introduced the rule: NULL comparisons return UNKNOWN, and WHERE treats UNKNOWN as a drop. This chapter is the deeper treatment — the truth tables behind every NULL behaviour, the four operators that handle NULLs explicitly, and the patterns that turn the three-valued logic from a footgun into a tool.
By the end you'll be able to read any predicate involving NULLs and predict its output; you'll know the difference between IS DISTINCT FROM and <>; you'll have the reflex to wrap nullable columns in COALESCE whenever a comparison would silently drop them.
Table of contents
- Three values, not two
- Truth tables
IS NULLandIS NOT NULLCOALESCE— pick the first non-NULLNULLIF— turn a sentinel into a real NULLIS DISTINCT FROM/IS NOT DISTINCT FROM- Aggregates and NULL
- Joins and NULL
- Edge cases and pitfalls
- Production reality
- Practice ladder
- Cross-links
- Final takeaway
Three values, not two
Boolean logic in most programming languages has two values: TRUE and FALSE. Every expression evaluates to one or the other.
SQL's logic has three values: TRUE, FALSE, and UNKNOWN (often displayed as NULL). The third is what makes SQL different.
UNKNOWN is what you get when an expression involves an unknown operand. Whenever any value in an expression is NULL, the result is generally UNKNOWN. The exceptions are tiny: IS NULL, IS NOT NULL, IS DISTINCT FROM, the COALESCE family — all carefully designed to give a definite TRUE or FALSE even when their inputs are NULL.
The single rule that explains every NULL bug:
WHERE(andJOIN ... ON,HAVING) keeps rows where the predicate isTRUE.FALSEandUNKNOWNboth drop the row.
Combine this with "NULL operations propagate NULL/UNKNOWN" and you have the entire mental model for NULL behaviour.
Truth tables
The exact behaviour of AND, OR, NOT over the three values. Memorising these saves you from re-deriving the answer every time you hit a NULL bug.
WHERE drops rows where the predicate is FALSE or UNKNOWN. The asymmetry between intentional FALSE and silent UNKNOWN is where every NULL bug lives.
AND:
| TRUE | FALSE | UNKNOWN | |
|---|---|---|---|
| TRUE | TRUE | FALSE | UNKNOWN |
| FALSE | FALSE | FALSE | FALSE |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |
OR:
| TRUE | FALSE | UNKNOWN | |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | UNKNOWN |
| UNKNOWN | TRUE | UNKNOWN | UNKNOWN |
NOT:
| Input | Output |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |
The pattern in plain English:
AND:FALSE"wins" — anythingAND FALSEisFALSE. OtherwiseUNKNOWNpropagates.OR:TRUE"wins" — anythingOR TRUEisTRUE. OtherwiseUNKNOWNpropagates.NOT: flipsTRUE/FALSE, leavesUNKNOWNalone.
That last cell — NOT UNKNOWN = UNKNOWN, not TRUE — is the trap. "Not equal to Germany" is UNKNOWN for a NULL-country row, not TRUE. So WHERE country != 'Germany' drops the NULL rows. Same with WHERE NOT (country = 'Germany').
IS NULL and IS NOT NULL
The only operators that always return TRUE or FALSE (never UNKNOWN) when applied to nullable values:
These are not value comparisons — they're type-test predicates. country IS NULL doesn't ask "is the value equal to some specific NULL value?" — it asks "does the row have no value in the country column?". That's a definite question with a definite yes/no answer.
IS [NOT] NULL is the only correct way to test for NULL. Never use = NULL or <> NULL — both always return UNKNOWN, no matter what the column contains.
COALESCE
COALESCE(a, b, c, ...) returns the first non-NULL argument. Returns NULL only if all arguments are NULL.
The canonical use: substitute a default for a NULL.
COALESCE is the workhorse. Use it whenever:
- You want a concatenation to not vanish when a part is NULL.
- You want a default for an aggregation that could be NULL (e.g.,
COALESCE(SUM(sales), 0)). - You want to compare against a value that "stands in" for NULL (
WHERE COALESCE(country, '') = ''matches both NULL and empty-string countries).
COALESCE is standard SQL and supported everywhere. (IFNULL(a, b) is a MySQL/SQLite shorthand for the two-argument case; NVL(a, b) is Oracle's version. COALESCE is portable.)
NULLIF
NULLIF(a, b) returns NULL if a = b, else returns a. The mirror of COALESCE.
The canonical use: safe divide.
For product 2 (units_sold = 0), NULLIF(0, 0) is NULL, so the division is revenue / NULL = NULL. No "division by zero" error — just a sensible NULL output that downstream consumers can handle.
The other use: turn sentinel values into real NULLs. Legacy data sometimes uses 'N/A', '', or -1 to mean "missing." NULLIF(legacy_col, 'N/A') converts those sentinels to real NULLs at read time.
IS DISTINCT FROM
The "null-safe equality" operator. Treats NULL as just another value:
a IS NOT DISTINCT FROM b: like a = b, but NULL is treated as equal to itself.
a IS DISTINCT FROM b: like a <> b, but NULL is treated as different from any non-NULL value (and equal to NULL).
Use cases:
-- "Show me rows where the country changed" — including NULL→non-NULL transitions.
SELECT * FROM audit
WHERE old_country IS DISTINCT FROM new_country;Standard <> would silently miss NULL→Germany transitions. IS DISTINCT FROM catches them.
📘 Dialect note: IS DISTINCT FROM is standard SQL and supported by Postgres, SQLite, SQL Server (since 2012). MySQL has the equivalent <=> operator (the "spaceship" operator) for IS NOT DISTINCT FROM. Other engines have varying support — check the docs.
Aggregates and NULL
A reminder from Aggregate Functions: aggregates silently exclude NULLs (with COUNT(*) as the exception).
| Aggregate | NULL handling |
|---|---|
COUNT(*) |
counts all rows including NULLs |
COUNT(col) |
counts only non-NULL values |
SUM/AVG/MIN/MAX |
ignores NULLs in input |
| Empty input | All except COUNT return NULL |
The often-surprising consequences:
AVG(score)on a column with NULLs isSUM(non-null) / COUNT(non-null)— notSUM(non-null) / COUNT(*). If you wanted NULLs to count as zero, useAVG(COALESCE(score, 0)).SUM(sales)over an empty result isNULL, not0. Wrap inCOALESCE(SUM(sales), 0)for safety.MIN(date)over a column with NULLs returns the earliest non-NULL. UseMIN(COALESCE(date, 'infinity'))orMIN(date) FILTER (WHERE date IS NOT NULL)for explicit handling.
Joins and NULL
JOIN ... ON a.x = b.y does not match rows where either side is NULL. Same NULL trap applied to a join condition. To deliberately match NULL on both sides, use IS NOT DISTINCT FROM:
JOIN ... ON a.x IS NOT DISTINCT FROM b.yBut this is rare in practice — usually NULL in a foreign-key column means "no relationship," and you don't want to match it against another NULL "no relationship" on the other side. Two "no relationships" aren't equivalent; they're both missing.
LEFT JOIN produces NULLs in the right-side columns when there's no match. WHERE right_col IS NULL is the standard "anti-join" pattern (covered in Anti-joins).
The trap: a WHERE right_col = ... predicate after a LEFT JOIN silently turns it into an INNER JOIN (also covered in Joins). The fix: put the inner-side filter in ON, not WHERE.
Edge cases and pitfalls
NULL ordering
ORDER BY has to put NULLs somewhere. Default is dialect-specific (Postgres puts NULLs last for ASC, first for DESC; SQL Server / MySQL go the other way). Always specify NULLS FIRST or NULLS LAST for deterministic output. (Covered in Ordering and Pagination.)
NULL in IN and NOT IN
SELECT * FROM customers WHERE id IN (1, 2, NULL); -- NULL doesn't match anything; returns 1, 2
SELECT * FROM customers WHERE id NOT IN (1, 2, NULL); -- TREACHEROUS: returns ZERO rows.NOT IN against a list with NULL is the famous bug from Filtering. Use NOT EXISTS instead.
NULL in unique constraints
UNIQUE allows multiple NULLs in standard SQL — because NULL = NULL is UNKNOWN, no two NULLs are "the same value." Postgres 15+ has UNIQUE NULLS NOT DISTINCT for the alternative semantics.
NULL in GROUP BY
GROUP BY treats NULL as a group of its own. All NULL rows collapse into one group with NULL as the group key.
CHECK constraints don't reject NULL
CHECK (score >= 0) allows NULL — the predicate is UNKNOWN for NULL, and CHECK only rejects rows where the predicate is FALSE. To require non-NULL, add NOT NULL separately.
CASE and NULL
SELECT
CASE WHEN score IS NULL THEN 'unknown' ELSE 'known' END
FROM customers;
-- vs (broken):
SELECT
CASE WHEN score = NULL THEN 'unknown' ELSE 'known' END
-- Returns 'known' for everyone — score = NULL is UNKNOWN, never TRUE.CASE WHEN x = NULL is a common bug. Use CASE WHEN x IS NULL instead.
Production reality
The chapter's hook bug is a real one — every churn dashboard has it once. The defensive pattern is to explicitly handle NULL at every comparison:
-- Safe "users who haven't logged in for 90 days, including never-logged-in users."
SELECT id, email
FROM users
WHERE COALESCE(last_login, DATE '1900-01-01') < CURRENT_DATE - INTERVAL '90 days';COALESCE(last_login, '1900-01-01') substitutes a far-past date for never-logged-in users — they're now eligible for the comparison and get included.
A second pattern — null-safe joins for slowly-changing dimensions:
SELECT *
FROM orders o
JOIN dim_customer dc
ON dc.customer_id = o.customer_id
AND dc.valid_from <= o.order_date
AND (dc.valid_to IS NULL OR dc.valid_to > o.order_date);dc.valid_to IS NULL represents "currently valid" in a temporal-dimension table. The OR ... IS NULL is essential — without it, currently-valid rows get dropped because NULL > o.order_date is UNKNOWN.
A third — NUMERIC defaults for empty windows:
-- Daily summary: events count and average per hour. COALESCE protects empty hours.
SELECT
hour_bucket,
COALESCE(COUNT(*), 0) AS events,
COALESCE(AVG(visits), 0) AS avg_visits
FROM (
SELECT generate_series(
DATE_TRUNC('hour', NOW() - INTERVAL '24 hours'),
DATE_TRUNC('hour', NOW()),
INTERVAL '1 hour'
) AS hour_bucket
) hours
LEFT JOIN hello_events e ON DATE_TRUNC('hour', TO_TIMESTAMP(e.timestamp_ms / 1000.0)) = hour_bucket
GROUP BY hour_bucket
ORDER BY hour_bucket;generate_series (Postgres) creates a row per hour. LEFT JOIN matches actual events. Empty hours produce NULL aggregates, which COALESCE fixes to 0. Without COALESCE, the dashboard chart has gaps; with it, every hour shows up.
Practice ladder
- Find customers with NULL country. Hint:
IS NULL. - Find customers whose country is anything other than NULL. Hint:
IS NOT NULL. - Predict the result of:
Hint: rows with NULL country are silently dropped.
SELECT * FROM customers WHERE country != 'Germany'; - Rewrite (3) to include NULL-country rows. Hint:
country IS DISTINCT FROM 'Germany'. OrWHERE country != 'Germany' OR country IS NULL. - Average score across customers, treating NULL as 0. Hint:
AVG(COALESCE(score, 0)). - Why does
SUM(sales)return NULL on an empty result, and how do you fix it? Hint:COALESCE(SUM(sales), 0). - Why does
WHERE NOT (country = 'Germany')not match NULL-country rows? Hint: walk through the truth table.NULL = 'Germany'isUNKNOWN.NOT UNKNOWNisUNKNOWN.WHEREdropsUNKNOWN. - Use
NULLIFto safely compute average sales per order. Hint:revenue * 1.0 / NULLIF(order_count, 0).
Cross-links
- Previous in this module: Dates and Times — date columns are commonly nullable; this chapter's patterns apply.
- Next in this module: CASE Expressions — the if-else construct that lets you encode NULL handling explicitly inline.
- Forward reference: Anti-joins and Existence — the
NOT EXISTSpattern that's null-safe by construction. - Cited from many chapters: Filtering, Joins, Aggregate Functions. NULL is the cross-cutting concern that touches every operator.
Final Takeaway
💡 Final takeaway.
NULL is the one piece of SQL that surprises every engineer at least once. Three patterns to internalise:
- NULL is "unknown," not "missing." Comparisons return
UNKNOWN, whichWHEREdrops. The asymmetry betweenFALSE(deliberately rejected) andUNKNOWN(silently rejected) is where every NULL bug lives. - Use
IS NULL,IS NOT NULL,IS DISTINCT FROM,COALESCE, andNULLIFto handle NULL explicitly. Each has its niche. The reflex to reach for one of them whenever a column might be nullable is the single biggest difference between a junior and a senior SQL writer. NOT INagainst a subquery is the most common NULL bug. The fix isNOT EXISTS, which is null-safe by construction. (Covered fully in Anti-joins and Existence.)
Master these three and NULL becomes a tool you wield deliberately, not a trap that catches you.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.