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. SQL uses three-valued logic. Comparisons with NULL evaluate to UNKNOWN. WHERE keeps TRUE and drops FALSE and UNKNOWN.
Two-Valued Logic Refresher
Figure 2. Two-valued boolean logic. AND requires TRUE on both sides. OR requires TRUE on at least one side. NOT flips the value.
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. Boolean operators sometimes return a definite value with NULL. Comparisons with NULL return UNKNOWN. IS NULL returns 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. COUNT(*) counts every row, so it returns 9. COUNT(rating) counts non-NULL values, so it returns 8. AVG(rating) divides by that same non-NULL count of 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