If you’ve ever written a SQL query like this:
SELECT * FROM users WHERE deleted_at = NULL;…and wondered why it returned zero rows, this article is for you.
The difference between = NULL and IS NULL is subtle, easy to miss, and responsible for countless production bugs, especially in multi-tenant systems, soft-delete logic, and ORM-based applications.
Let’s break it down properly.
In SQL, NULL does not mean:
- 0
- empty string ''
- false
It means:
“Unknown / no value”
Because the value is unknown, SQL cannot compare it using normal comparison operators like = or !=.
Unlike most programming languages, SQL uses three-valued logic:
| Value | Meaning |
|---|---|
| TRUE | Condition is true |
| FALSE | Condition is false |
| UNKNOWN | Condition cannot be determined |
Any comparison involving NULL results in UNKNOWN.
Consider this query:
SELECT * FROM users WHERE last_login = NULL;last_login = UNKNOWNAnd since WHERE clauses only return rows when the condition is TRUE, all rows are excluded.
❌ Always returns zero rows
Even if last_login is actually NULL.
SQL provides special operators to handle NULL values:
IS NULL
IS NOT NULLSELECT * FROM users WHERE last_login IS NULL;✅ Returns all rows where last_login has no value.
| Expression | Result |
|---|---|
field = NULL |
❌ Always fails |
field != NULL |
❌ Always fails |
field IS NULL |
✅ Correct |
field IS NOT NULL |
✅ Correct |
| Expression | Result |
|---|---|
NULL = NULL |
UNKNOWN |
NULL != NULL |
UNKNOWN |
NULL IS NULL |
TRUE |
NULL IS NOT NULL |
FALSE |
Imagine a system where:
company_id = NULL→ global datacompany_id = 5→ company-specific data
SELECT * FROM statuses
WHERE company_id = NULL OR company_id = 5;This query never returns global rows.
SELECT * FROM statuses
WHERE company_id IS NULL OR company_id = 5;This is a very common mistake in SaaS and CRM systems.
Many bugs come from developers assuming ORM comparisons behave like Python.
query.filter(Model.company_id == None)query.filter(Model.company_id.is_(None))Or:
query.filter(Model.company_id.isnot(None))SQLAlchemy correctly translates these into:
company_id IS NULL
company_id IS NOT NULLIn most databases (PostgreSQL, MySQL):
NULL != NULL- Multiple
NULLvalues are allowed in aUNIQUEcolumn
This is why partial unique indexes are often required when dealing with defaults:
CREATE UNIQUE INDEX uq_default_per_company
ON statuses (company_id)
WHERE is_default = TRUE;- ✔
NULLis not a value — it’s the absence of a value - ✔ Never use
=or!=withNULL - ✔ Always use
IS NULLorIS NOT NULL - ✔ This mistake silently breaks queries (no errors, just wrong data)
- ✔ ORM users must use framework-specific NULL checks
If you are checking for
NULL, useIS, not=
One character difference.
Massive impact.