SQL NULL Handling — Common Pitfalls and Fixes

Published: September 11, 2026 | Updated: September 11, 2026

NULL is the only value in SQL that is not equal to itself. Write WHERE email = NULL and you get zero rows back — not because every email is missing, but because the comparison itself evaluates to UNKNOWN, and WHERE keeps only rows whose predicate is TRUE. That single rule explains most NULL bugs that reach production: the NOT IN query that silently returns an empty set, the average that is too high because rows were quietly dropped, the sort order that flips when you move from MySQL to PostgreSQL.

This guide covers the mechanics first — what NULL actually represents, and how three-valued logic changes AND, OR, and NOT — then the five failures people hit in real code: comparing with =, NOT IN against a nullable column, COUNT(col) versus COUNT(*), COALESCE dialect differences, and NULL ordering. Every one gets a concrete example and a fix you can paste into your query.

Table of Contents
  1. 1. What NULL Means (and What It Doesn't)
  2. 2. Three-Valued Logic: TRUE, FALSE, UNKNOWN
  3. 3. Why = NULL Never Matches
  4. 4. The NOT IN Trap
  5. 5. Aggregates Ignore NULL: COUNT(col) vs COUNT(*)
  6. 6. COALESCE and the Dialect Zoo
  7. 7. NULLIF: The Underrated One
  8. 8. Sorting NULLs: ORDER BY and NULLS FIRST/LAST
  9. 9. Schema Design: NOT NULL and DEFAULT
  10. FAQ

1. What NULL Means (and What It Doesn't)

NULL marks the absence of a value. It is not zero, not an empty string, and not false. Those are all real values that happen to be small; NULL is the absence of information — the database has nothing to report for that cell.

ValueMeansExample column
0A known quantity of zerobalance = 0 — the account is empty, and we know it
''A known string of length zeromiddle_name = '' — recorded, and known to be blank
NULLNo value / unknown / not applicableshipped_at = NULL — the order has not shipped yet

The distinction matters because the first two are answerable questions and the third is not. "What is the balance?" has answer 0. "When did this ship?" has no answer at all.

NULL propagates through expressions. Any arithmetic or function call that touches NULL returns NULL unless the function is written to handle it explicitly:

SELECT
    NULL + 1                 AS a,   -- NULL  (arithmetic propagates)
    LENGTH(NULL)             AS b,   -- NULL  (string functions too)
    UPPER(NULL)              AS c,   -- NULL
    NULL < 5                AS d,   -- NULL  (comparison yields UNKNOWN)
    COALESCE(NULL + 1, 0)    AS e;   -- 0     (handled explicitly)

Two practical notes before the rest of the article:

2. Three-Valued Logic: TRUE, FALSE, UNKNOWN

SQL predicates are not two-valued. Any comparison involving NULL produces a third result, UNKNOWN, and WHERE filters it out because it keeps only rows where the condition is TRUE. UNKNOWN and FALSE are both discarded — which is why a NULL row disappears from col = 5 and from col <> 5.

AND truth table

ANDTRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

FALSE wins in an AND: if one side is definitely false, the whole thing is false even when the other side is unknown.

OR truth table

ORTRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

TRUE wins in an OR, for the same reason: one known-true side settles it.

NOT

NOTResult
NOT TRUEFALSE
NOT FALSETRUE
NOT UNKNOWNUNKNOWN

That last row is the one that bites. Negating an unknown predicate does not make it true — it stays unknown, and the row stays out of the result. A column status with values 'active', 'paused', and NULL behaves like this:

-- Rows with status = NULL are excluded from BOTH queries:
SELECT * FROM accounts WHERE status = 'active';      -- returns active only
SELECT * FROM accounts WHERE NOT (status = 'active'); -- returns paused only

If you want the NULL rows in that second result, say so explicitly: WHERE status IS NULL OR status <> 'active'. There is no shortcut — the engine cannot guess that you meant "everything except active" to include the rows it knows nothing about.

Rule of thumb: whenever a predicate could run against a NULLable column, ask "what should happen to the NULL rows?" and write that branch by hand. NULL never fails loudly; it just removes rows.

3. Why = NULL Never Matches

col = NULL produces UNKNOWN for every row — including rows where col is NULL, because NULL is not a value and "unknown = unknown" is still unknown. WHERE col = NULL therefore returns an empty set on a table full of NULLs. The operator is the problem, so SQL gives you a dedicated test instead of an equality operator: IS NULL and IS NOT NULL are the only correct way to ask about NULL.

-- All of these return zero rows, no matter how much data is NULL:
SELECT * FROM users WHERE last_login =  NULL;
SELECT * FROM users WHERE last_login != NULL;
SELECT * FROM users WHERE last_login <> '2026-01-01';  -- also drops the NULL rows

-- Correct forms:
SELECT * FROM users WHERE last_login IS NULL;
SELECT * FROM users WHERE last_login IS NOT NULL;

-- Null-safe equality: 'IS DISTINCT FROM' is true when values differ OR exactly one is NULL
-- (PostgreSQL, and SQL standard engines such as DuckDB and Oracle 23c)
SELECT * FROM users WHERE last_login IS DISTINCT FROM '2026-01-01';

The null-safe operators are worth knowing because they let you compare "including NULLs" without a pile of OR clauses:

OperatorDialectBehavior
IS NOT DISTINCT FROMSQL standard, PostgreSQL, DuckDBTRUE when both are NULL, and when both are equal non-NULLs
<=>MySQL, MariaDBNULL-safe equal: returns 1 when both sides are NULL
ISNULL(a, b)SQL ServerNot a comparison — returns b when a is NULL

A related trap that costs people an afternoon: <> and NOT IN look like they should return "everything else", but they silently exclude NULL rows. Whenever you write a negative predicate against a nullable column, decide explicitly whether NULL belongs in the answer.

4. The NOT IN Trap

This is the most damaging NULL bug in everyday SQL, because the query runs without error and returns an empty result that looks like a valid answer. Set up two small tables:

customers: id 1, 2, 3
orders:    (id 1, customer_id 1), (id 2, customer_id 1), (id 3, customer_id NULL)

-- "Which customers have never placed an order?"
SELECT id FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
-- Returns 0 rows. Expectation: customer 2 and 3.

One NULL customer_id in the subquery poisons the entire predicate. id NOT IN (1, NULL) expands to id <> 1 AND id <> NULL. That second term is UNKNOWN for every row, and TRUE AND UNKNOWN is UNKNOWN — so no row ever passes. The subquery does not even need to contain a NULL that belongs to a real customer; a single nullable column with one missing value is enough.

Two fixes, both reliable:

-- Fix 1: NOT EXISTS with a correlated subquery. NULL-safe, and usually the best plan.
SELECT c.id
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

-- Fix 2: keep NOT IN but strip NULLs inside the subquery.
SELECT id
FROM customers
WHERE id NOT IN (
    SELECT customer_id FROM orders WHERE customer_id IS NOT NULL
);

Both return customers 2 and 3. Prefer NOT EXISTS: it does not require the subquery to be NULL-free, it handles multi-column anti-joins naturally (NOT EXISTS (SELECT 1 FROM o WHERE o.a = c.a AND o.b = c.b)), and most optimizers turn it into an anti-join hash or merge instead of evaluating the list per row.

Interview-grade detail: IN has the mirror behavior. WHERE id IN (1, NULL) does not become empty — it still matches 1, because TRUE OR UNKNOWN is TRUE. Only the negated form collapses, which is exactly why the bug shows up in NOT IN and nobody documents it until a production report comes back blank.

Format and validate NULL-heavy queries before they reach production

Paste a query with COALESCE, IS NULL, and NOT IN into the formatter to catch the structure at a glance, then validate the syntax before you run it.

Open SQL Format Tool →

5. Aggregates Ignore NULL: COUNT(col) vs COUNT(*)

Every aggregate except COUNT(*) skips NULL inputs. SUM, AVG, MIN, and MAX evaluate only the rows where the argument is not NULL — and that changes the answers in ways that are easy to miss.

-- accounts.balance: 100, NULL, 200

SELECT
    COUNT(*)                   AS all_rows,        -- 3   every row
    COUNT(balance)             AS rows_with_data,  -- 2   NULL excluded
    SUM(balance)               AS total,           -- 300
    AVG(balance)               AS avg_balance,     -- 150 NOT 100
    COALESCE(SUM(balance), 0)  AS total_or_zero    -- 300 (0 when the group is all NULL)
FROM accounts;

The AVG result is the one that surprises people. It divides by the non-NULL count, not by the row count, so 300 / 2 = 150 rather than 300 / 3 = 100. If your business rule says a missing balance counts as zero, you must say so — AVG(COALESCE(balance, 0)) gives 100, and that is a deliberate analytical choice, not the database's job to infer.

Two more behaviors to internalize:

6. COALESCE and the Dialect Zoo

COALESCE returns the first argument that is not NULL. It is SQL standard, exists in every major engine, and takes an arbitrary number of arguments evaluated left to right, stopping at the first non-NULL. Reach for it instead of vendor functions whenever the query might travel.

SELECT
    username,
    COALESCE(nickname, display_name, username, 'anonymous') AS shown_name,
    COALESCE(phone, 'not provided')                         AS contact_phone
FROM users;

-- Backfill a NULLable column with a real default value
UPDATE orders SET discount = 0 WHERE discount IS NULL;

The same idea goes by four different names across engines. They are not interchangeable in code you intend to port:

FunctionEnginesArgumentsNotes
COALESCEAll engines (SQL standard)VariadicPreferred. Short-circuits at the first non-NULL result.
IFNULLMySQL, MariaDB, SQLiteExactly 2No standard alternative name needed — COALESCE covers it.
NVLOracleExactly 2Oracle-specific. NVL2(a, b, c) returns b when a is not NULL, else c.
ISNULLSQL ServerExactly 2In MySQL, ISNULL(x) is a different function: a one-argument test returning 1 or 0.

Two subtleties worth knowing before you pick a favorite:

NULL and string concatenation

Concatenation propagates NULL. In PostgreSQL, Oracle, SQL Server, and MySQL's CONCAT, stitching a NULL into a string yields NULL for the whole expression — so a single missing middle name blanks out a full name built from three columns:

-- NULL if either column is NULL
SELECT first_name || ' ' || last_name AS full_name FROM users;

-- Safe: neutralize each part first
SELECT COALESCE(first_name, '') || ' ' || COALESCE(last_name, '') AS full_name FROM users;

-- MySQL convention, which also collapses NULLs to empty strings
SELECT CONCAT_WS(' ', first_name, middle_name, last_name) AS full_name FROM users;

CONCAT_WS ("with separator") skips NULL arguments entirely — a MySQL and SQL Server convenience whose Oracle and PostgreSQL equivalents you would have to build with COALESCE.

7. NULLIF: The Underrated One

NULLIF(a, b) returns NULL when a = b, and returns a otherwise. It sounds backward until you see the two jobs it does well.

Guard against division by zero

Dividing by zero is an error in PostgreSQL, MySQL, and SQL Server (Oracle raises it too), so a naive average-per-group query can fail on exactly the rows that have no data yet. Wrapping the denominator in NULLIF(expr, 0) converts the zero into NULL, and any arithmetic with NULL yields NULL — a missing answer instead of a crashed query:

-- Crashes when orders_count = 0
SELECT revenue / orders_count AS avg_order_value FROM daily_stats;

-- Returns NULL for those rows instead
SELECT revenue / NULLIF(orders_count, 0) AS avg_order_value FROM daily_stats;

-- With a readable fallback
SELECT COALESCE(revenue / NULLIF(orders_count, 0), 0) AS avg_order_value FROM daily_stats;

Turn a sentinel into a real NULL

Legacy tables love magic values — '' for "no email", 'N/A' for "unknown region". NULLIF normalizes them to NULL at read time so that IS NULL, aggregates, and COALESCE all behave consistently, without touching the stored data:

SELECT
    NULLIF(region_code, '')     AS region_code,   -- '' becomes NULL
    NULLIF(status, 'UNKNOWN')   AS status,        -- sentinel becomes NULL
    NULLIF(email, 'n/a')        AS email
FROM staging_imports;

Note that because NULLIF is built on equality, NULLIF(x, NULL) can never fire — the comparison is UNKNOWN. It only matches real values, which is fine for sentinel cleanup and useless for NULL detection, where IS NULL is what you want.

8. Sorting NULLs: ORDER BY and NULLS FIRST/LAST

Sort position for NULL differs by engine, but the SQL standard does not require a particular order — so a query that looks portable can produce two different reports. PostgreSQL and Oracle treat NULL as larger than any value (ascending puts NULLs last); MySQL, SQLite, and SQL Server treat it as smaller (ascending puts NULLs first).

EngineNULL position, ASCNULL position, DESCNULLS FIRST/LAST
PostgreSQLLastFirstSupported
OracleLastFirstSupported
SQLite 3.30+FirstLastSupported
MySQL / MariaDBFirstLastNot supported (workaround below)
SQL ServerFirstLastNot supported (workaround below)

Where the clause exists, use it explicitly rather than relying on engine defaults — it documents the intent and makes the query portable:

-- Most recent logins first, but actually seen users before never-seen ones
SELECT name, last_login
FROM users
ORDER BY last_login DESC NULLS LAST;

MySQL and SQL Server have no NULLS FIRST / NULLS LAST syntax, so you sort on a boolean sort key first. (col IS NULL) evaluates to 0 for real values and 1 for NULLs, so an ascending sort on that key pushes NULLs last:

-- MySQL / MariaDB: real values first, NULLs last
SELECT name, last_login FROM users ORDER BY last_login IS NULL, last_login DESC;

-- SQL Server: same idea with CASE
SELECT name, last_login FROM users
ORDER BY CASE WHEN last_login IS NULL THEN 1 ELSE 0 END, last_login DESC;

For NULLS FIRST, invert the key: ORDER BY last_login IS NOT NULL, last_login. And if NULLs must never appear in the output at all, filter instead of sorting around them — WHERE last_login IS NOT NULL is cheaper than any sort-key trick.

9. Schema Design: NOT NULL and DEFAULT

Half of production NULL bugs are decided at CREATE TABLE time. Two constraints do the work, and they do different jobs:

CREATE TABLE users (
    id          BIGINT PRIMARY KEY,
    email       VARCHAR(255) NOT NULL,
    created_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    credits     INTEGER      NOT NULL DEFAULT 0,
    deleted_at  TIMESTAMP    NULL                      -- NULL = not deleted
);

-- DEFAULT applies: credits becomes 0
INSERT INTO users (id, email) VALUES (1, 'a@example.com');

-- DEFAULT is bypassed: this inserts NULL, which NOT NULL would reject
INSERT INTO users (id, email, credits) VALUES (2, 'b@example.com', NULL);
-- ERROR: null value in column "credits" violates not-null constraint

That is the single most misunderstood interaction in schema design. A column declared credits INTEGER DEFAULT 0 without NOT NULL will happily store NULL the moment a client sends an explicit NULL, and then every SUM(credits) silently drops those rows. If the column should always have a value, declare both: NOT NULL DEFAULT 0. The default handles the polite inserts; the constraint handles the rest.

Three more decisions that are cleaner when made up front:

Migration checklist: before adding NOT NULL to an existing column, run SELECT COUNT(*) FROM t WHERE col IS NULL. Backfill first with UPDATE ... SET col = <default> WHERE col IS NULL, then apply the constraint. On large tables, do the backfill in batches so you do not hold a long lock.

FAQ — SQL NULL Handling

Why does WHERE col = NULL return no rows?

Because NULL is not a value you can test with =. The expression col = NULL evaluates to UNKNOWN for every row, including rows where col is actually NULL, and WHERE keeps only rows whose predicate is TRUE. Use IS NULL or IS NOT NULL, or a null-safe operator such as IS DISTINCT FROM (PostgreSQL) or <=> (MySQL).

What is the difference between COUNT(*) and COUNT(column)?

COUNT(*) counts every row in the group. COUNT(column) counts only rows where that column is not NULL. On a 1000-row table where 400 emails are NULL, COUNT(*) is 1000 and COUNT(email) is 600. The same NULL-skipping applies to SUM, AVG, MIN and MAX.

Is COALESCE the same as IFNULL, NVL, and ISNULL?

They all return the first non-NULL argument, but they are not portable. COALESCE is SQL standard, works in every engine, and takes any number of arguments. IFNULL is MySQL/MariaDB/SQLite with two arguments. NVL is Oracle with two. ISNULL is SQL Server with two, but in MySQL ISNULL(x) is a one-argument boolean test returning 1 or 0, not a substitution at all.

How do I fix a NOT IN query that returns no rows?

If the subquery returns even one NULL, NOT IN becomes UNKNOWN for every outer row and the result is empty. Either add WHERE column IS NOT NULL inside the subquery, or rewrite the predicate as NOT EXISTS with a correlated join, which is NULL-safe and usually gets a better plan.

Where do NULLs sort in ORDER BY?

It depends on the engine. PostgreSQL and Oracle treat NULL as larger than any value, so ASC puts NULLs last. MySQL, SQLite and SQL Server treat it as smaller, so ASC puts NULLs first. PostgreSQL, Oracle and SQLite 3.30+ accept an explicit NULLS FIRST or NULLS LAST; MySQL and SQL Server need ORDER BY (col IS NULL), col or a CASE sort key.

Is NULL the same as an empty string or zero?

No. An empty string and the number 0 are real values that compare equal to themselves, while NULL means no value was supplied. A few consequences that bite people: COUNT('') counts the row but COUNT(nullable_col) does not; CHECK (col <> '') passes for NULL rows in most engines; and Oracle stores an empty string as NULL, so col = '' never matches there. Test for a missing value with IS NULL, never with = '' or = 0.