What NULL actually means

NULL is not a value — it's the absence of one. It means "we don't know" or "not applicable." That's fundamentally different from an empty string '' (a known, zero-length text) or 0 (a known number). Because it represents the unknown, NULL refuses to behave like a normal value in comparisons.

One-line mental model: read NULL as the word "unknown." "Is unknown equal to 5?" — nobody can say, so the answer is UNKNOWN, not yes or no.

Why = NULL never works (three-valued logic)

Most languages have two boolean values. SQL has three: TRUE, FALSE, and UNKNOWN. Any comparison involving NULL produces UNKNOWN:

ExpressionResult
NULL = NULLUNKNOWN
NULL = 5UNKNOWN
NULL <> 5UNKNOWN
NULL + 10NULL
'' = NULLUNKNOWN

A WHERE clause keeps a row only when its condition is TRUE. Since col = NULL is UNKNOWN (never TRUE), it matches nothing — no error, just zero rows.

equals-null.sql
-- ❌ Returns 0 rows, even if some phones really are NULL
SELECT * FROM users WHERE phone = NULL;

IS NULL / IS NOT NULL — the right way

IS NULL and IS NOT NULL are the only correct way to test for NULLs. They return TRUE/FALSE (never UNKNOWN), so they actually filter rows.

is-null.sql
-- ✅ Rows with a missing phone
SELECT * FROM users WHERE phone IS NULL;

-- ✅ Rows that do have a phone
SELECT * FROM users WHERE phone IS NOT NULL;
Watch out for negation: WHERE status <> 'active' silently drops rows where status is NULL, because NULL <> 'active' is UNKNOWN. If you want those rows too, write WHERE status <> 'active' OR status IS NULL.

The NOT IN + NULL trap

This one bites even experienced developers. If the list or subquery inside NOT IN contains a single NULL, the entire expression becomes UNKNOWN for every row — so you get zero rows back.

not-in-trap.sql
-- ❌ Returns nothing if any manager_id is NULL
SELECT name FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);

-- ✅ Fix A: NOT EXISTS is NULL-safe
SELECT e.name FROM employees e
WHERE NOT EXISTS (
  SELECT 1 FROM employees m WHERE m.manager_id = e.id
);

-- ✅ Fix B: exclude NULLs from the NOT IN list
SELECT name FROM employees
WHERE id NOT IN (
  SELECT manager_id FROM employees WHERE manager_id IS NOT NULL
);

How aggregates treat NULL

Aggregate functions ignore NULLs — which is usually what you want, but it changes the math:

  • SUM, MIN, MAX skip NULLs.
  • AVG divides by the number of non-NULL values, not the row count — so NULLs don't drag the average toward zero.
  • COUNT(column) skips NULLs; COUNT(*) counts every row. (Full breakdown in COUNT(*) vs COUNT(column).)
If you want NULLs to count as zero in an average, convert them first: AVG(COALESCE(score, 0)) averages over all rows treating missing scores as 0.

NULL in GROUP BY, DISTINCT & ORDER BY

For grouping and de-duplication, SQL makes a pragmatic exception: it treats all NULLs as "the same" so they don't scatter.

  • GROUP BY puts all NULLs into a single group.
  • DISTINCT keeps just one NULL.
  • ORDER BY sorts NULLs together, but where they land differs by database. PostgreSQL and Oracle treat NULLs as largest (last in ascending); MySQL and SQL Server put them first. Be explicit with NULLS FIRST / NULLS LAST where supported.
nulls-last.sql
-- PostgreSQL / Oracle: control NULL placement explicitly
SELECT name, phone FROM users
ORDER BY phone ASC NULLS LAST;

Replacing NULLs: COALESCE, IFNULL, NULLIF

To substitute a default for missing values, use COALESCE — it's standard SQL and returns the first non-NULL argument.

coalesce.sql
-- Show 'N/A' when phone is missing
SELECT name, COALESCE(phone, 'N/A') AS phone
FROM users;

-- Vendor-specific equivalents (same idea)
-- MySQL:      IFNULL(phone, 'N/A')
-- SQL Server: ISNULL(phone, 'N/A')
-- Oracle:     NVL(phone, 'N/A')

-- NULLIF does the reverse: return NULL when two values match
SELECT NULLIF(discount, 0) AS discount FROM orders;  -- 0 becomes NULL
Prefer COALESCE over the vendor functions for portable code — it works everywhere and accepts more than two arguments: COALESCE(mobile, home, work, 'N/A').

NULL cheat sheet

GoalDo this
Test if a value is NULLcol IS NULL
Test if a value existscol IS NOT NULL
Exclude a value and keep NULLscol <> x OR col IS NULL
Safe "not in a set"NOT EXISTS (…)
Default for missing valuesCOALESCE(col, default)
Turn a value into NULLNULLIF(col, x)

Key takeaways

  • NULL means unknown — not zero, not empty string, and not equal to another NULL.
  • Any comparison with NULL is UNKNOWN, so = NULL matches nothing. Use IS NULL / IS NOT NULL.
  • <> conditions silently drop NULL rows — add OR col IS NULL if you want them.
  • NOT IN with any NULL returns zero rows — use NOT EXISTS instead.
  • Aggregates ignore NULLs; replace them with COALESCE when you need a default.