NULL Values: The Unknown in SQL
Concept. NULL means "unknown," not 0 or empty. Any comparison with NULL returns UNKNOWN, a third truth value, so a WHERE test drops that row.
Intuition. Leave a listen's rating blank and SQL stores NULL. Ask "is rating > 4?" about an unknown and the answer is UNKNOWN, not yes or no, so WHERE quietly drops the row and only IS NULL finds it.
Figure 1. NULL is SQL's third truth value. In the binary world a comparison is TRUE or FALSE; compare anything with NULL and the answer is UNKNOWN. NULL needs its own rules because no comparison can pin down that third value.
Two-Valued Logic Refresher
Figure 2. Boolean logic before NULL enters: AND needs both operands TRUE, OR needs at least one, NOT flips. Two values in, two values out.
In two-valued logic, every condition is TRUE or FALSE. Four operators:
-
AND:
TRUE AND FALSE → FALSE. Both must be TRUE. -
OR:
TRUE OR FALSE → TRUE. At least one must be TRUE. -
NOT:
NOT TRUE → FALSE. Flips the value. -
= (equality):
4 = 4 → TRUE. Matches values.
SQL Adds NULL: A Third Truth Value
A missing value is stored as NULL, meaning "unknown". That adds a third truth value beyond TRUE and FALSE: UNKNOWN.
(SQL standard: NULL is the value, UNKNOWN is the truth value. Some databases like SQLite also use None/NaN.)
What Changes When NULL Enters
Read each example as a question. 4.5 > NULL → NULL asks "is 4.5 greater than NULL?" The answer is NULL because we don't know NULL's value.
Figure 3. Two groups of rules. With operators a known value can dominate: FALSE wins an AND, TRUE wins an OR, so the result is sometimes definite. Comparisons never dominate: anything compared to NULL is UNKNOWN. Only IS NULL escapes the unknown and gives a real TRUE or FALSE.
Five patterns: a FALSE forces an AND to FALSE; a TRUE forces an OR to TRUE; NOT of NULL stays NULL; any comparison with NULL is UNKNOWN; only IS NULL gives a definite TRUE or FALSE.
NULL also has its own quirks in aggregates and DISTINCT, covered in their own sections below. GROUP BY and JOINs handle NULL with similar quirks; those live on the GROUP BY and JOINs pages.
NULL in Comparisons
-- Test how NULL behaves with different comparison operators
SELECT
listen_id,
rating,
rating = 4.5,
rating > 4.0,
rating IS NULL
FROM Listens
WHERE listen_id IN (1, 3, 9)
| listen_id | rating | rating = 4.5 | rating > 4.0 | rating IS NULL |
|---|---|---|---|---|
| 1 | 4.5 | true | true | false |
| 3 | 3.9 | false | false | false |
| 9 | NULL | null | null | true |
Row 9 demonstrates rule 4: every comparison with NULL evaluates to NULL. To find that row you need IS NULL (rule 5).
NULL with AND/OR Logic
Figure 4. One row, two operators. Daffy's user_id = 3 is TRUE but rating > 4 is UNKNOWN. Under AND the UNKNOWN drops the row; under OR the TRUE keeps it. The operator decides whether a NULL survives.
-- Demonstrate three-valued logic with AND/OR operations
SELECT
listen_id,
user_id,
rating,
(user_id = 3 AND rating > 4.0) AS and_result,
(user_id = 3 OR rating > 4.0) AS or_result
FROM Listens
WHERE listen_id IN (1, 2, 9)
| listen_id | user_id | rating | and_result | or_result | |
|---|---|---|---|---|---|
| 1 | 1 | 4.5 | false | true | |
| 2 | 1 | 4.2 | false | true | |
| 9 | 3 | NULL | NULL | true |
Logic Demonstration
-
Rows 1 & 2: user_id = 3 is FALSE, rating > 4 is TRUE → AND returns FALSE, OR returns TRUE
-
Row 9: TRUE AND NULL → NULL
-
Row 9: TRUE OR NULL → TRUE (TRUE dominates in OR)
NULL in WHERE Clause
Figure 5. One NULL row, three filters. IS NULL (rule 5) returns TRUE and keeps row 9; both rating > 4.0 and rating <= 4.0 evaluate to UNKNOWN on a NULL, and WHERE keeps only TRUE rows, so the NULL is gone from both.
Query 1: Find the NULL ratings
SELECT listen_id, rating
FROM Listens
WHERE rating IS NULL
| listen_id | rating | |
|---|---|---|
| 9 | NULL |
1 row: Daffy's listen. IS NULL (rule 5) returns a definite TRUE for row 9, so WHERE keeps it.
The other two filters drop row 9.
Query 2: WHERE rating > 4.0
| listen_id | rating | |
|---|---|---|
| 1 | 4.5 | |
| 2 | 4.2 | |
| 4 | 4.7 | |
| 5 | 4.6 | |
| 8 | 4.9 |
5 rows. Row 9 missing.
Query 3: WHERE rating <= 4.0
| listen_id | rating | |
|---|---|---|
| 3 | 3.9 | |
| 6 | 3.9 | |
| 7 | 2.9 |
3 rows. Row 9 missing again.
5 + 3 + 1 = 9 rows total. Daffy's NULL row drops from Queries 2 and 3:
How WHERE handles row 9 across all three filters:
| filter | row 9 evaluates to | WHERE action |
|---|---|---|
rating IS NULL | TRUE | keeps |
rating > 4.0 | NULL | drops |
rating <= 4.0 | NULL | drops |
By rule 4, every comparison with NULL is NULL. WHERE keeps only TRUE rows, so the NULL is gone from both. Only IS NULL (rule 5) returns a definite TRUE and finds Daffy.
NULL in Aggregates
Figure 6. The NULL row is counted once and skipped twice. COUNT(*) counts every row (9). COUNT(rating) and the math aggregates skip the NULL, so AVG divides by 8, not 9.
-- Compare how aggregates handle NULL values
SELECT
COUNT(*) AS total_rows,
COUNT(rating) AS rated_rows,
AVG(rating) AS avg_rating,
SUM(rating) AS sum_rating,
MIN(rating) AS min_rating,
MAX(rating) AS max_rating
FROM Listens
| total_rows | rated_rows | avg_rating | sum_rating | min_rating | max_rating | Notes |
|---|---|---|---|---|---|---|
| 9 | 8 | 4.2 | 33.6 | 2.9 | 4.9 | COUNT(*) counts all; others skip NULL |
Calculation Details:
-
COUNT(*): All 9 rows in table
-
COUNT(rating): Only 8 non-NULL values (row 9 has NULL)
-
AVG(rating): (4.5+4.2+3.9+4.7+4.6+3.9+2.9+4.9)/8 = 4.2
-
SUM(rating): Sum of 8 non-NULL values = 33.6
-
MIN(rating): Minimum non-NULL value = 2.9
-
MAX(rating): Maximum non-NULL value = 4.9
Key Points
-
COUNT(*) counts every row
-
COUNT(column) skips NULLs
-
AVG/SUM/MIN/MAX ignore NULLs
-
AVG divides by non-NULL count
NULL in DISTINCT
-- Get unique rating values (NULLs treated as one group)
SELECT DISTINCT rating
FROM Listens
| rating | Annotations |
|---|---|
| NULL | NULL forms one unique value |
| 2.9 | From listen_id 7 |
| 3.9 | Keeping only one (from listen_ids 3, 6) |
| 4.2 | From listen_id 2 |
| 4.5 | From listen_id 1 |
| 4.6 | From listen_id 5 |
| 4.7 | From listen_id 4 |
| 4.9 | From listen_id 8 |
DISTINCT treats all NULLs as one unique value, even though NULL = NULL is NULL by rule 4. (GROUP BY behaves the same way; covered on the GROUP BY page.)
Common Mistakes
The = NULL Trap
Rule 4 violation (NULL wins). rating = NULL returns NULL on every row, and WHERE drops NULL.
-- WRONG: rating = NULL always returns NULL (not TRUE)
SELECT * FROM Listens WHERE rating = NULL
-- CORRECT: Use IS NULL to properly test for NULL
SELECT * FROM Listens WHERE rating IS NULL
The NOT NULL Gotcha
Rules 3 + 4 violation (NOT NULL stays NULL; comparisons with NULL produce NULL). When rating is NULL, rating > 4.0 is NULL, and NOT NULL is still NULL, so WHERE keeps only TRUE rows and the row is gone.
-- WRONG: NOT (rating > 4.0) still excludes NULLs
SELECT * FROM Listens WHERE NOT (rating > 4.0)
-- CORRECT: Explicitly include NULLs if you want them
SELECT * FROM Listens
WHERE rating <= 4.0 OR rating IS NULL
COUNT(*) vs COUNT(column)
Aggregate behavior (separate from the logic rules above). SUM, AVG, MIN, MAX, and COUNT(column) all skip NULLs. Only COUNT(*) counts every row.
-- COUNT(*) vs COUNT(column) behave differently with NULLs
SELECT COUNT(*) AS all_rows, -- Returns 9
COUNT(rating) AS rated_rows -- Returns 8
FROM Listens