JOIN two tables on a nullable column and the rows disappear.
Insert two NULLs into a UNIQUE column and neither insert fails.
Both problems share the same root: SQL defines NULL as unknown, and unknown is never equal to anything β including another unknown.
What NULL Actually Means
In SQL, NULL means “unknown value” β not zero, not empty string, not false.
| |
NULL = NULL returns NULL, not TRUE. This single rule is why JOINs and UNIQUE constraints behave unexpectedly with NULL.
Trap 1: JOIN on NULL Columns Returns No Rows
The Scenario
Two tables where orders.customer_id can be NULL. You want to JOIN them to find the customer for each order:
| |
| |
Result:
| |
orders.id = 1 vanished, even though both orders.customer_id and customers.id are NULL.
Why
The INNER JOIN condition is o.customer_id = c.id. When both values are NULL, NULL = NULL evaluates to NULL, not TRUE. The row fails the join condition and gets filtered out.
Solutions
Option 1: IS NOT DISTINCT FROM (SQL standard, treats NULL = NULL as true)
| |
NULL IS NOT DISTINCT FROM NULL evaluates to TRUE.
Option 2: COALESCE to replace NULL
| |
Substitutes NULL with -1 so the = comparison succeeds. Choose a sentinel that can’t be a real ID.
Option 3: LEFT JOIN with explicit NULL handling
| |
Database support
| Syntax | MySQL | PostgreSQL | SQLite | SQL Server |
|---|---|---|---|---|
IS NOT DISTINCT FROM | β (8.0.17+) | β | β | β (2022+) |
<=> NULL-safe equality | β | β | β | β |
COALESCE | β | β | β | β |
MySQL has its own NULL-safe equality operator:
| |
Trap 2: UNIQUE Constraint Allows Multiple NULLs
The Scenario
You design a users table with a nullable email column. Users don’t have to provide an email, but if they do, it should be unique:
| |
Insert two rows with email = NULL:
| |
SQLite, PostgreSQL, SQL Server: both inserts succeed. No error. MySQL (InnoDB): also succeeds.
The UNIQUE constraint does nothing for NULL values.
Why
A UNIQUE constraint works by checking whether the new value equals any existing value. Since NULL != NULL, every NULL is treated as a distinct unknown β multiple NULLs all pass the uniqueness check.
This is specified behavior, not a bug. SQLite, PostgreSQL, SQL Server, MySQL InnoDB, and Oracle all follow it.
Solutions
Option 1: Partial Index (PostgreSQL, SQLite)
Build a UNIQUE index only over non-NULL values:
| |
NULL values aren’t indexed at all, so they bypass the uniqueness check. Non-NULL emails are still enforced as unique.
Option 2: MySQL Filtered Index
| |
Option 3: SQL Server Filtered Index
| |
Option 4: NOT NULL + empty string (not recommended)
| |
Both Traps, One Root Cause
Both problems come from the same design principle: NULL means unknown, not empty.
| Operation | NULL behavior | Outcome |
|---|---|---|
NULL = NULL | Unknown whether equal | NULL (not TRUE) |
NULL IS NULL | Checks for NULL | TRUE |
NULL IS NOT DISTINCT FROM NULL | NULL-safe comparison | TRUE |
JOIN NULL = NULL | Fails the join condition | Row filtered out |
| UNIQUE with multiple NULLs | Each NULL is a different unknown | All pass |
Summary
When a JOIN is dropping rows, check whether the ON condition columns can be NULL. Use IS NOT DISTINCT FROM for a NULL-safe comparison, or MySQL’s <=>.
For UNIQUE on a nullable column, a plain UNIQUE constraint isn’t enough. Use a Partial Index (WHERE column IS NOT NULL) to enforce uniqueness only for non-NULL values.
When designing a schema, decide explicitly whether a column should be nullable. Don’t make it nullable by default β NULL interacts with constraints and JOINs in ways that aren’t obvious until something silently breaks.
