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.

NULL adds a third truth value, UNKNOWN. Binary tests are TRUE (4.5 > 4.0) or FALSE (4.5 > 5.0), but any comparison with NULL (4.5 > NULL, NULL = NULL, rating = NULL) is UNKNOWN, shown grey with a question mark. WHERE keeps only TRUE rows, so UNKNOWN and NULL are gone; rating IS NULL is the only test that finds them.

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

Two-valued boolean logic truth tables. AND: only TRUE AND TRUE is TRUE; any FALSE makes it FALSE. OR: any TRUE makes it TRUE; only FALSE OR FALSE is FALSE. NOT flips TRUE to FALSE and FALSE to TRUE.

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.

NULL's rules in two groups. Operators: a value dominates. FALSE AND NULL is FALSE (false wins), TRUE OR NULL is TRUE (true wins), TRUE AND NULL and FALSE OR NULL and NOT NULL are all NULL (NULL stays). Comparisons go UNKNOWN: 4.5 > NULL, 4 = NULL, NULL = NULL all NULL (NULL wins); only rating IS NULL returns a real TRUE or FALSE (the only real test).

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

Daffy's row (user_id = 3, rating = NULL) under two operators. user_id = 3 is TRUE; rating > 4 is UNKNOWN. Under AND: TRUE AND UNKNOWN is UNKNOWN, so WHERE drops the row. Under OR: TRUE OR UNKNOWN is TRUE, so WHERE keeps it. Same row, two operators, two fates.

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_iduser_idratingand_resultor_result
114.5falsetrue
214.2falsetrue
93NULLNULLtrue

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

The same NULL row across three WHERE filters, side by side. Query 1 WHERE rating IS NULL keeps row 9 (Daffy, NULL). Query 2 WHERE rating > 4.0 returns 5 rows and Query 3 WHERE rating <= 4.0 returns 3 rows, both without row 9: every comparison with NULL is UNKNOWN, which WHERE drops.

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_idrating
9NULL

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_idrating
14.5
24.2
44.7
54.6
84.9

5 rows. Row 9 missing.

Query 3: WHERE rating <= 4.0

listen_idrating
33.9
63.9
72.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:

filterrow 9 evaluates toWHERE action
rating IS NULLTRUEkeeps
rating > 4.0NULLdrops
rating <= 4.0NULLdrops

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

The Listens.rating column has nine values, one of them NULL. COUNT(*) returns 9: it counts every row, including the NULL. COUNT(rating) returns 8: it skips the NULL row. AVG(rating) returns 4.2, the sum of the eight known ratings, 33.6, divided by 8, not 9.

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_rowsrated_rowsavg_ratingsum_ratingmin_ratingmax_ratingNotes
984.233.62.94.9COUNT(*) 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
ratingAnnotations
NULLNULL forms one unique value
2.9From listen_id 7
3.9Keeping only one (from listen_ids 3, 6)
4.2From listen_id 2
4.5From listen_id 1
4.6From listen_id 5
4.7From listen_id 4
4.9From 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