Home Database SQL NULL Handling - 340 Employees Missing Bonuses
Beginner 7 min · March 05, 2026

SQL NULL Handling - 340 Employees Missing Bonuses

340 employees missed bonus payments because NULL arithmetic produced $0 compensation.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Written from production experience, not tutorials.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 20 min
  • Basic programming fundamentals
  • A computer with internet access
  • Willingness to follow along with examples
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • NULL means unknown or absent — not zero, not empty string, not false
  • NULL comparisons always return UNKNOWN (not TRUE or FALSE) — = NULL, != NULL, < NULL all produce UNKNOWN
  • Use IS NULL and IS NOT NULL — these are the only correct ways to test for NULL
  • COALESCE(a, b, c) returns the first non-NULL value — the standard NULL-to-default replacement
  • NULLIF(a, b) returns NULL if a equals b — converts a specific value to NULL on purpose
  • Biggest mistake: NULL arithmetic — any expression involving NULL produces NULL (5 + NULL = NULL)
✦ Definition~90s read
What is SQL NULL Handling?

SQL NULL is not a value — it's a marker for missing or unknown data, and it breaks every assumption you have about how comparisons and logic work. The core problem: NULL = NULL evaluates to unknown, not true, so WHERE bonus = NULL returns zero rows even when NULLs exist.

Imagine you're filling out a form and you leave the 'middle name' box completely blank — not a dash, not a space, just nothing at all.

This three-valued logic (true, false, unknown) infects WHERE clauses, JOIN conditions, and CHECK constraints, silently dropping rows from results. You must use IS NULL, IS DISTINCT FROM, or functions like COALESCE to handle it explicitly.

COALESCE replaces NULL with a fallback value — COALESCE(bonus, 0) turns missing bonuses into zeros for calculations. NULLIF does the reverse: NULLIF(column, 0) converts a known value to NULL, useful for avoiding division-by-zero or excluding defaults from aggregates. Aggregate functions like AVG() and SUM() ignore NULLs entirely, which can skew results — COUNT(*) counts rows, but COUNT(column) counts only non-NULL values, a distinction that burns teams in reporting.

The real cost is performance: NULLs in indexed columns force databases to store extra bitmap pages (PostgreSQL) or skip index-only scans (SQL Server), because the index can't prove a NULL doesn't exist. A WHERE bonus IS NULL query on a 10M-row table might scan the whole index instead of seeking.

In practice, this 'nullability tax' means you should avoid nullable columns in primary keys, unique constraints, and join columns unless you're willing to pay the query-plan penalty. Tools like dbt and ORMs (ActiveRecord, Entity Framework) often default columns to nullable, silently degrading performance until you add NOT NULL constraints.

Plain-English First

Imagine you're filling out a form and you leave the 'middle name' box completely blank — not a dash, not a space, just nothing at all. That blank box is NULL in SQL. It doesn't mean zero, it doesn't mean empty string, it means 'we simply don't know or don't have this information.' Because NULL represents the absence of a value, normal math and comparisons don't work on it the same way — you can't ask 'is nothing equal to nothing?' and get a sensible yes or no.

Every real-world database has missing data. A customer who didn't provide a phone number. An order with no shipping date yet because it hasn't shipped. An employee whose bonus hasn't been decided. These gaps are everywhere, and SQL has a special way of representing them — NULL. If you don't understand NULL, your queries will silently return wrong answers without throwing a single error, which is one of the most dangerous bugs a database can have.

The tricky part is that NULL breaks the rules you're used to. In normal life, if you ask 'is blank equal to blank?' you'd say yes. SQL says 'I don't know' — and that 'I don't know' answer is what causes queries to behave unexpectedly. Filters exclude rows you expected to include, calculations return NULL when you expected a number, and JOINs quietly drop data. SQL gives you specific tools — IS NULL, IS NOT NULL, COALESCE, and NULLIF — to handle these gaps safely and deliberately.

By the end of this article you'll understand what NULL actually means (and what it doesn't), why you can't use = NULL in a WHERE clause, how to filter and replace NULLs with confidence, and how to avoid the silent bugs that trip up even experienced developers. You'll be writing NULL-safe SQL from scratch.

What NULL Actually Is — and Why = NULL Never Works

NULL isn't a value — it's the absence of a value. Think of it like a sealed envelope. You don't know what's inside. If someone hands you two sealed envelopes and asks 'are these the same?', the honest answer is 'I have no idea' — not yes, not no. SQL agrees. Comparing anything to NULL using = produces a third logical state called UNKNOWN, which is neither TRUE nor FALSE.

This is why WHERE salary = NULL will never return any rows, even if the salary column genuinely contains NULLs. SQL evaluates the condition, gets UNKNOWN for every row, and excludes them all. WHERE only keeps rows where the condition is TRUE.

SQL gives you a dedicated operator for NULL checks: IS NULL and IS NOT NULL. These are the only correct ways to test whether a value is absent. They return TRUE or FALSE — no UNKNOWN — which is exactly what WHERE needs to filter rows correctly.

This also means NULL = NULL is NOT true in SQL. Two unknown things are not automatically equal. This catches beginners off guard constantly, especially when writing JOIN conditions.

null_basics_check.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- Create a simple employees table to demonstrate NULL behavior
CREATE TABLE employees (
    employee_id   INT PRIMARY KEY,
    full_name     VARCHAR(100) NOT NULL,
    department    VARCHAR(50),
    phone_number  VARCHAR(20),   -- nullable: not every employee has provided one
    annual_bonus  DECIMAL(10,2)  -- nullable: bonus not yet decided for some staff
);

-- Insert sample rows — some have NULLs, some don't
INSERT INTO employees VALUES (1, 'Alice Mercer',   'Engineering', '555-0101', 4500.00);
INSERT INTO employees VALUES (2, 'Ben Okafor',     'Marketing',   NULL,       3200.00);
INSERT INTO employees VALUES (3, 'Clara Huang',    'Engineering', '555-0303', NULL);
INSERT INTO employees VALUES (4, 'David Nkosi',    'HR',          NULL,       NULL);
INSERT INTO employees VALUES (5, 'Eva Johansson',  'Marketing',   '555-0505', 2800.00);

-- ❌ WRONG: This returns 0 rows even though NULLs exist in phone_number
-- WHERE evaluates phone_number = NULL as UNKNOWN for every row
SELECT full_name
FROM   employees
WHERE  phone_number = NULL;
-- Output: (0 rows) — silent bug!

-- ✅ CORRECT: Use IS NULL to find employees without a phone number
SELECT full_name
FROM   employees
WHERE  phone_number IS NULL;
-- Output: Ben Okafor, David Nkosi

-- ✅ CORRECT: Use IS NOT NULL to find employees who DO have a phone number
SELECT full_name, phone_number
FROM   employees
WHERE  phone_number IS NOT NULL;
-- Output: Alice Mercer | 555-0101
--         Clara Huang  | 555-0303
--         Eva Johansson| 555-0505
Output
-- phone_number = NULL query:
(0 rows returned)
-- IS NULL query:
full_name
----------
Ben Okafor
David Nkosi
-- IS NOT NULL query:
full_name | phone_number
----------------|-------------
Alice Mercer | 555-0101
Clara Huang | 555-0303
Eva Johansson | 555-0505
⚠ Watch Out:
Never use = NULL or != NULL in a WHERE clause. They will silently return zero rows without any error message. SQL won't warn you — it'll just quietly drop your data. Always use IS NULL or IS NOT NULL.
📊 Production Insight
The three-valued logic of SQL (TRUE, FALSE, UNKNOWN) is the source of countless production bugs.
NULL in a WHERE condition produces UNKNOWN, which is treated as FALSE — the row is excluded.
CONCAT with NULL produces NULL in some databases (PostgreSQL) and skips NULL in others (MySQL) — use COALESCE before CONCAT.
🎯 Key Takeaway
NULL = NULL is UNKNOWN, not TRUE — never use = or != to test for NULL.
Any expression involving NULL produces NULL — the result is unknown, not zero or false.
IS NULL and IS NOT NULL are the only correct operators for NULL tests.
sql-null-handling SQL NULL Handling Architecture Layered approach to managing NULLs in databases Data Layer Raw Table | NULL Values Expression Layer COALESCE | NULLIF | IS NULL Aggregation Layer SUM | AVG | COUNT Index Layer B-Tree Index | Partial Index Logic Layer Three-Valued Logic | Boolean Operations THECODEFORGE.IO
thecodeforge.io
Sql Null Handling

COALESCE — Replacing NULL With a Sensible Default

Now that you can detect NULLs, the next question is: what do you do with them? Often you want to replace a NULL with a default value so your reports and application logic get something useful instead of a blank.

That's exactly what COALESCE does. You give it a list of values and it hands back the first one that isn't NULL. Think of it like a backup plan — 'give me the first option, but if that's blank, try the second, and if that's blank too, try the third.'

Coalesce is standard SQL and works in MySQL, PostgreSQL, SQL Server, SQLite, and Oracle. It's one of the most-used NULL-handling functions in production databases.

A key use case is calculated columns. If annual_bonus is NULL and you add it to a base salary, the entire result becomes NULL — SQL says 'I can't add a known number to an unknown number and give you a real answer.' COALESCE lets you substitute zero (or any default) so your arithmetic stays intact.

COALESCE can take more than two arguments. COALESCE(a, b, c, d) returns the first non-NULL across all four — really useful when you have primary, secondary, and fallback data sources.

coalesce_bonus_report.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- COALESCE(value_to_check, fallback_if_null)
-- Returns the first argument that is NOT NULL

-- Use case 1: Display a friendly label instead of NULL in a report
SELECT
    full_name,
    COALESCE(phone_number, 'No phone on file') AS contact_number
FROM employees;

-- Use case 2: Safe arithmetic — treat a missing bonus as 0
-- Without COALESCE, NULL bonus would make the entire total NULL
SELECT
    full_name,
    80000                             AS base_salary,
    COALESCE(annual_bonus, 0)         AS bonus,           -- swap NULL for 0
    80000 + COALESCE(annual_bonus, 0) AS total_compensation
FROM employees;

-- Use case 3: Multiple fallbacks — try preferred_email, then work_email, then a default
-- (Imaginary columns to show the pattern)
-- COALESCE(preferred_email, work_email, 'no-email@company.com')

-- Use case 4: COALESCE in a WHERE clause to filter on computed values
SELECT full_name
FROM   employees
WHERE  COALESCE(annual_bonus, 0) < 3000;  -- treats NULL bonus as 0 for comparison
Output
-- Contact number report:
full_name | contact_number
----------------|------------------
Alice Mercer | 555-0101
Ben Okafor | No phone on file
Clara Huang | 555-0303
David Nkosi | No phone on file
Eva Johansson | 555-0505
-- Total compensation report:
full_name | base_salary | bonus | total_compensation
----------------|-------------|---------|-------------------
Alice Mercer | 80000 | 4500.00 | 84500.00
Ben Okafor | 80000 | 3200.00 | 83200.00
Clara Huang | 80000 | 0.00 | 80000.00
David Nkosi | 80000 | 0.00 | 80000.00
Eva Johansson | 80000 | 2800.00 | 82800.00
-- Employees with bonus under 3000 (NULLs treated as 0):
full_name
----------
Clara Huang
David Nkosi
Eva Johansson
💡Pro Tip:
COALESCE is the standard SQL way to handle NULLs. MySQL also has IFNULL(value, fallback) which does the same job but only takes two arguments. COALESCE is more portable — use it when you care about your SQL running across different database engines.
📊 Production Insight
COALESCE is evaluated lazily — it returns the first non-NULL from left to right and stops evaluating further arguments.
COALESCE(a, expensive_function()) is safe — the function only runs if a is NULL.
For default display values in reports, COALESCE(col, 'N/A') is cleaner than CASE WHEN col IS NULL THEN 'N/A' ELSE col END.
🎯 Key Takeaway
COALESCE is the standard NULL-to-default function — use it in any column that might be NULL in reports.
COALESCE(a, b, c) returns the first non-NULL — multiple fallback values are supported.
Always COALESCE numeric columns before arithmetic when NULL should mean zero.

NULLIF and Aggregate Functions — NULLs You Create on Purpose

COALESCE turns NULLs into real values. NULLIF does the opposite — it turns a specific real value into NULL. Why on earth would you want that?

The classic example is preventing division by zero. If you divide by zero in SQL, you get an error. But if you use NULLIF(denominator, 0), SQL turns any zero denominator into NULL — and dividing by NULL returns NULL instead of crashing. You can then wrap that in COALESCE to display something readable.

Aggregate functions like COUNT, SUM, AVG, MIN, and MAX have a specific and important relationship with NULL: they all silently skip NULL values. This is usually what you want — averaging a salary column where some salaries aren't set yet should average the known salaries, not treat unknowns as zero. But it means COUNT(*) and COUNT(column_name) give different answers, which surprises many beginners.

COUNT(*) counts every row. COUNT(phone_number) counts only rows where phone_number is not NULL. This distinction matters enormously in reports.

nullif_and_aggregates.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
-- NULLIF(expression, unwanted_value)
-- Returns NULL if expression equals unwanted_value, otherwise returns expression

-- Use case: safe division — avoid divide-by-zero error
-- Imagine a sales table with targets and actual sales
CREATE TABLE sales_performance (
    rep_name    VARCHAR(100),
    target      INT,
    actual_sold INT
);

INSERT INTO sales_performance VALUES ('Nia Patel',    100, 85);
INSERT INTO sales_performance VALUES ('Omar Silva',   0,   0);  -- new rep, no target set
INSERT INTO sales_performance VALUES ('Priya Chen',   200, 210);

-- ❌ WRONG: This crashes when target = 0
-- SELECT actual_sold / target FROM sales_performance;

-- ✅ CORRECT: NULLIF converts 0 target to NULL, avoiding the crash
-- COALESCE then turns the NULL result into a readable message
SELECT
    rep_name,
    target,
    actual_sold,
    COALESCE(
        CAST(actual_sold AS DECIMAL) / NULLIF(target, 0) * 100,
        0
    ) AS achievement_pct   -- 0 when target was NULL/zero
FROM sales_performance;

-- ─────────────────────────────────────────
-- Aggregate functions and NULL — key demo
-- ─────────────────────────────────────────

-- COUNT(*) counts ALL rows including those with NULLs
SELECT COUNT(*) AS total_employees
FROM   employees;
-- Returns: 5

-- COUNT(column) counts only NON-NULL values in that column
SELECT COUNT(annual_bonus) AS employees_with_bonus
FROM   employees;
-- Returns: 3  (Clara and David have NULL bonus — they are skipped)

-- AVG also skips NULLs — it averages only the 3 known bonuses
SELECT AVG(annual_bonus) AS average_known_bonus
FROM   employees;
-- Returns: 3500.00  (4500 + 3200 + 2800) / 3

-- If you want NULL bonuses treated as 0 in the average:
SELECT AVG(COALESCE(annual_bonus, 0)) AS average_bonus_including_none
FROM   employees;
-- Returns: 2100.00  (4500 + 3200 + 0 + 0 + 2800) / 5
Output
-- Safe division result:
rep_name | target | actual_sold | achievement_pct
------------|--------|-------------|----------------
Nia Patel | 100 | 85 | 85.00
Omar Silva | 0 | 0 | 0.00
Priya Chen | 200 | 210 | 105.00
-- COUNT(*): 5
-- COUNT(annual_bonus): 3
-- AVG(annual_bonus): 3500.00
-- AVG with COALESCE: 2100.00
🔥Interview Gold:
Interviewers love asking 'what's the difference between COUNT() and COUNT(column_name)?' The answer is that COUNT() includes NULL rows in its count; COUNT(column_name) does not. Knowing this cold will impress any SQL interviewer.
📊 Production Insight
NULLIF is the least-used NULL function but solves division-by-zero cleanly: revenue / NULLIF(clicks, 0) returns NULL instead of dividing by zero.
DIVIDE BY NULL produces NULL (not an error) in SQL — the NULLIF pattern exploits this to avoid exceptions.
NULLIF is also used to turn sentinel values like 0, -1, or 'N/A' into NULL for proper NULL handling downstream.
🎯 Key Takeaway
NULLIF(a, b) = NULL when a equals b — use it to turn zero into NULL before division to avoid divide-by-zero.
NULLIF is the clean alternative to CASE WHEN divisor = 0 THEN NULL ELSE numerator / divisor END.
Aggregate functions skip NULLs — converting unwanted values to NULL with NULLIF makes aggregates exclude them.
sql-null-handling NULL Handling: COALESCE vs NULLIF Comparing two common SQL functions for NULL management COALESCE NULLIF Purpose Replace NULL with default Create NULL from equal values Syntax COALESCE(expr, default) NULLIF(expr1, expr2) Use Case Missing bonuses set to 0 Avoid division by zero Result Returns first non-NULL Returns NULL if equal Aggregate Impact Includes default in SUM Excludes from AVG THECODEFORGE.IO
thecodeforge.io
Sql Null Handling

The Nullability Tax — Why Your Indexes Lie to You

You've been burned by this before: a query that should fly runs a full table scan. You check the execution plan, and there it is — a NULL-friendly column wrecking your index selectivity. NULLs are not values, but SQL Server still stores them. In a B-tree index, every NULL goes into the same linked list. That means a seek becomes a scan of all NULL rows before you find anything useful.

This isn't a bug. It's a design decision that punishes lazy schema design. If you filter WHERE column IS NULL, the engine can't do a simple equality check. It has to chase pointers. The fix? Filtered indexes. CREATE INDEX idx_active_orders ON orders (status) WHERE status IS NOT NULL. That index only lives where the data exists. Your query suddenly becomes logarithmic instead of linear.

The WHY is simple: NULL breaks the binary assumption of value comparison. Indexes rely on ordered value sets. NULL has no order. Don't let your database pretend otherwise.

FilteredIndexFix.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — database tutorial

CREATE TABLE orders (
    order_id INT IDENTITY PRIMARY KEY,
    status VARCHAR(20) NULL,
    total DECIMAL(10,2)
);

// Without filtered index — full scan on status
SELECT order_id FROM orders WHERE status = 'SHIPPED';

// Create filtered index for non-null statuses only
CREATE INDEX idx_orders_shipped
    ON orders (status)
    WHERE status IS NOT NULL;

// Now the seek works 
SELECT order_id FROM orders WHERE status = 'SHIPPED';
Output
Execution plan before: Table Scan (100% cost)
Execution plan after: Index Seek (nonclustered) (1% cost)
⚠ Production Trap:
A filtered index won't help queries filtering on IS NULL. Those still need a full scan. Design your schema so critical columns are NOT NULL if you need fast null lookups.
🎯 Key Takeaway
Filtered indexes turn NULL-crippled columns into performant weapons. Use them or accept the full table scan.

Three-Valued Logic — The Boolean Trap That Eats Joins

Here's where juniors burn hours debugging: you write a LEFT JOIN with a WHERE clause, and rows vanish. The culprit is three-valued logic. SQL doesn't do true/false like your programming language. It has TRUE, FALSE, and UNKNOWN. Any comparison with NULL produces UNKNOWN. And WHERE clauses only return rows where the condition is TRUE. UNKNOWN is treated as false.

Example: SELECT * FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.total > 100. This kills all customers with no orders — because NULL > 100 is UNKNOWN, not FALSE. The join returns them, but the WHERE filter discards them. You just turned a LEFT JOIN into an INNER JOIN without knowing it.

The fix is explicit: move the condition into the JOIN clause itself. LEFT JOIN orders o ON c.id = o.customer_id AND o.total > 100. Or use COALESCE to provide a default. But understand the logic first. Every time you write a WHERE clause on a nullable column from an outer join, you're playing Russian roulette with your result set.

JoinLogicMeltdown.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — database tutorial

CREATE TABLE customers (
    id INT PRIMARY KEY,
    name VARCHAR(50)
);

CREATE TABLE orders (
    id INT PRIMARY KEY,
    customer_id INT NULL,
    total DECIMAL(10,2)
);

INSERT INTO customers VALUES (1, 'Alice'), (2, 'Bob');
INSERT INTO orders VALUES (100, 1, 250.00), (101, NULL, NULL);

// The broken query — Alice vanishes
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.total > 100;

-- Output: (empty set if no orders match)

// The correct query
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.total > 100;
Output
Broken query output:
name | total
------+-------
(0 rows)
Correct query output:
name | total
-------+-------
Alice | 250.00
Bob | NULL
🔥Senior Shortcut:
Use the WHERE clause only on columns from the primary table of a LEFT JOIN. If you must filter on the nullable side, move the condition into the ON clause or use a subquery with IS NOT NULL inside.
🎯 Key Takeaway
Three-valued logic turns LEFT JOINs into INNER JOINs silently. Filter the nullable table in the ON clause, not the WHERE clause.

A Real-World Scenario

Why bother with NULL handling? Because NULLs silently break business logic in production. Consider an employee bonus system: your query calculates annual bonus as base_salary * performance_multiplier. If performance_multiplier is NULL (manager didn't submit review), the result is NULL, not zero. Payroll runs, employees with missing reviews get zero bonus — without any error or warning. The real cost hits when accounting runs reconciliation: the total bonus payout doesn't match the query output by thousands of dollars. You need to catch these NULL-drift paths before they corrupt downstream reports.

BonusNullDrift.sqlSQL
1
2
3
4
5
6
7
8
9
// io.thecodeforge — database tutorial

-- NULL multiplies through: NULL * 50000 = NULL, not 0
SELECT 
  employee_id,
  base_salary * performance_multiplier AS bonus_nulls,
  COALESCE(base_salary * performance_multiplier, 0) AS bonus_safe
FROM employee_performance
WHERE performance_multiplier IS NULL;
Output
+-------------+------------+------------+
| employee_id | bonus_nulls| bonus_safe |
+-------------+------------+------------+
| 2041 | NULL | 0 |
| 2097 | NULL | 0 |
+-------------+------------+------------+
⚠ Production Trap:
Arithmetic with NULL never raises an error — it silently returns NULL. Always wrap multiplication chains in COALESCE at the final expression, not inside the calculation.
🎯 Key Takeaway
One NULL in a calculation chain poisons the entire result — always test with IS NULL to find drift paths.

Wrapping Up

Three lessons to carry forward. First, NULL is not a value — it's a marker for the absence of a value, and SQL's three-valued logic (TRUE, FALSE, UNKNOWN) means your WHERE clauses can silently exclude rows you expect to include. Second, use COALESCE to replace NULLs with defaults at query output, but never inside JOIN conditions — that masks missing relationships and corrupts referential integrity. Third, NULLIF and NULL-handling in aggregates let you intentionally exclude data (e.g., divide-by-zero prevention). Every NULL in your schema imposes a cognitive tax: each query writer must remember where NULLs live and how they propagate. Normalize your data to avoid optional columns where possible, and document every nullable column's business meaning in your schema comments.

NullGuardPattern.sqlSQL
1
2
3
4
5
6
7
8
9
10
// io.thecodeforge — database tutorial

-- Final pattern: safe aggregation with NULL handling
SELECT 
  department_id,
  COUNT(*) AS total_employees,
  COUNT(salary) AS employees_with_salary,
  AVG(COALESCE(bonus, 0)) AS avg_bonus_with_default
FROM employees
GROUP BY department_id;
Output
+---------------+----------------+------------------------+---------------------+
| department_id | total_employees| employees_with_salary | avg_bonus_with_def. |
+---------------+----------------+------------------------+---------------------+
| 10 | 25 | 23 | 487.30 |
| 20 | 17 | 17 | 210.00 |
+---------------+----------------+------------------------+---------------------+
🔥Key Insight:
COUNT(column) counts non-NULL values only; COUNT(*) counts rows. Use this distinction to detect data quality issues in your tables.
🎯 Key Takeaway
Treat NULL as a design smell — minimize nullable columns and wrap all aggregations in explicit NULL handling.

NULL vs DBNULL vs DEFAULT: Database-Specific Behavior

Different databases treat NULL, DBNULL, and DEFAULT values in distinct ways, which can lead to subtle bugs when porting SQL code. In SQL Server, DBNull is a .NET concept representing a database null, but in T-SQL, NULL is the only representation. MySQL treats NULL and DEFAULT differently: a column defined with a DEFAULT value will use that value when no explicit value is provided, but inserting NULL explicitly stores NULL, not the default. PostgreSQL follows the SQL standard strictly: NULL means unknown, and DEFAULT is used only when the column is omitted in an INSERT. For example, in PostgreSQL:

```sql CREATE TABLE employees ( id SERIAL PRIMARY KEY, name TEXT, bonus NUMERIC DEFAULT 0 );

INSERT INTO employees (name) VALUES ('Alice'); -- bonus = 0 (DEFAULT) INSERT INTO employees (name, bonus) VALUES ('Bob', NULL); -- bonus = NULL ```

In MySQL, the same behavior applies, but with a nuance: if the column is defined as NOT NULL DEFAULT 0, inserting NULL will cause an error or be converted to the default depending on the SQL mode. SQL Server behaves similarly to PostgreSQL but with ISNULL() as a T-SQL extension. Understanding these differences is crucial when writing cross-database applications or migrating data.

null-vs-default.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- PostgreSQL: NULL vs DEFAULT
CREATE TABLE test (
    id INT,
    val INT DEFAULT 0
);

INSERT INTO test (id) VALUES (1);      -- val = 0 (DEFAULT)
INSERT INTO test (id, val) VALUES (2, NULL); -- val = NULL
INSERT INTO test (id, val) VALUES (3, DEFAULT); -- val = 0 (explicit DEFAULT)

SELECT * FROM test;

-- MySQL: NULL vs DEFAULT (strict mode off)
INSERT INTO test (id, val) VALUES (4, NULL); -- val = NULL
INSERT INTO test (id) VALUES (5);            -- val = 0 (DEFAULT)

-- SQL Server: similar to PostgreSQL
INSERT INTO test (id, val) VALUES (6, NULL); -- val = NULL
INSERT INTO test (id) VALUES (7);            -- val = 0 (DEFAULT)
⚠ DBNull is not SQL NULL
📊 Production Insight
When designing tables, avoid mixing NULL and DEFAULT for the same column unless you have a clear semantic distinction. Use CHECK constraints or triggers to enforce business rules about nullability.
🎯 Key Takeaway
NULL means unknown; DEFAULT is used only when the column is omitted. Inserting NULL explicitly stores NULL, not the default, in all major databases.

NULL in Indexes: PostgreSQL vs MySQL vs SQL Server

How databases index NULL values varies significantly and affects query performance. PostgreSQL treats NULLs as distinct values and includes them in B-tree indexes by default. In PostgreSQL, NULLs are considered larger than all non-null values (unless using NULLS FIRST/LAST). MySQL's InnoDB also indexes NULLs, but they are stored at the beginning of the index (like NULLS FIRST). SQL Server indexes NULLs as well, but they are treated as equal to each other and are stored at the end of the index (like NULLS LAST). This difference impacts queries with IS NULL or IS NOT NULL conditions. For example, in PostgreSQL, an index on a nullable column can efficiently support WHERE col IS NULL because NULLs are indexed. In SQL Server, the same index also supports IS NULL efficiently. However, in MySQL, IS NULL can use the index but may require scanning a range of NULL entries. A critical nuance: in PostgreSQL, a unique index allows multiple NULL values (since NULL != NULL), while in SQL Server and MySQL, a unique constraint also allows multiple NULLs. But in older MySQL versions (before 5.7), a unique index allowed only one NULL. Always check your database version. For partial indexes (PostgreSQL), you can create an index that only includes rows where the column is NULL, improving performance for sparse null checks.

null-indexes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- PostgreSQL: index on nullable column
CREATE INDEX idx_bonus ON employees(bonus);
EXPLAIN ANALYZE SELECT * FROM employees WHERE bonus IS NULL;
-- Uses index scan

-- MySQL: similar index
CREATE INDEX idx_bonus ON employees(bonus);
EXPLAIN SELECT * FROM employees WHERE bonus IS NULL;
-- May use index, but NULLs are stored first

-- SQL Server: index on nullable column
CREATE INDEX idx_bonus ON employees(bonus);
SET STATISTICS IO ON;
SELECT * FROM employees WHERE bonus IS NULL;
-- Uses index seek

-- PostgreSQL partial index for NULLs only
CREATE INDEX idx_bonus_null ON employees(bonus) WHERE bonus IS NULL;
EXPLAIN ANALYZE SELECT * FROM employees WHERE bonus IS NULL;
-- Very efficient for sparse NULLs
🔥Indexing NULLs: Not All Databases Are Equal
📊 Production Insight
If you frequently query for NULL values on a large table, consider a partial index (PostgreSQL) or a filtered index (SQL Server) that only includes NULL rows. This reduces index size and speeds up scans.
🎯 Key Takeaway
All major databases index NULL values, but their ordering and uniqueness behavior differ. Use database-specific features like partial indexes (PostgreSQL) or filtered indexes (SQL Server) for optimal NULL query performance.

COALESCE, NULLIF, ISNULL: Function Comparison Across Dialects

SQL provides several functions to handle NULLs, but their syntax and behavior vary across databases. COALESCE is standard SQL and returns the first non-null argument. It is supported in PostgreSQL, MySQL, SQL Server, and others. NULLIF returns NULL if two expressions are equal, otherwise returns the first expression. ISNULL is a T-SQL function in SQL Server that replaces NULL with a specified value, but it is not standard. MySQL has IFNULL which does the same. PostgreSQL does not have ISNULL; use COALESCE instead. For example:

```sql -- COALESCE: standard, works everywhere SELECT COALESCE(bonus, 0) AS effective_bonus FROM employees;

-- NULLIF: returns NULL if equal SELECT NULLIF(commission, 0) AS non_zero_commission FROM sales;

-- ISNULL (SQL Server only) SELECT ISNULL(bonus, 0) FROM employees;

-- IFNULL (MySQL only) SELECT IFNULL(bonus, 0) FROM employees; ```

A common pitfall: ISNULL in SQL Server takes only two arguments, while COALESCE can take multiple. Also, COALESCE evaluates arguments lazily in some databases, but not all. In PostgreSQL, COALESCE evaluates all arguments before checking for NULL, which can cause side effects if using functions. NULLIF is often used to avoid division by zero: NULLIF(denominator, 0). In MySQL, IFNULL is limited to two arguments, so use COALESCE for more. For cross-database compatibility, prefer COALESCE and NULLIF over vendor-specific functions.

null-functions.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- COALESCE: standard, multi-argument
SELECT COALESCE(bonus, commission, 0) AS payment FROM employees;

-- NULLIF: prevent division by zero
SELECT amount / NULLIF(quantity, 0) AS unit_price FROM sales;

-- SQL Server ISNULL (two args only)
SELECT ISNULL(bonus, 0) FROM employees;

-- MySQL IFNULL (two args only)
SELECT IFNULL(bonus, 0) FROM employees;

-- PostgreSQL: no ISNULL, use COALESCE
SELECT COALESCE(bonus, 0) FROM employees;

-- Oracle: NVL (two args) or NVL2 (three args)
SELECT NVL(bonus, 0) FROM employees;
SELECT NVL2(bonus, 'has bonus', 'no bonus') FROM employees;
💡Prefer Standard Functions
📊 Production Insight
When writing stored procedures or views that may be migrated, stick to COALESCE and NULLIF. For performance, note that COALESCE may evaluate all arguments in some databases, so avoid expensive subqueries as arguments.
🎯 Key Takeaway
COALESCE and NULLIF are standard SQL functions for NULL handling. Avoid vendor-specific functions like ISNULL or IFNULL unless you are certain the database will never change.
● Production incidentPOST-MORTEMseverity: high

Bonus Payments Not Sent to 340 Employees Due to NULL Arithmetic

Symptom
340 employees didn't receive their quarterly payment. Payroll ran successfully without errors. The employees' base salary existed; their bonus_amount was NULL (not yet approved for that quarter).
Assumption
The calculation base_salary + bonus_amount would treat NULL bonus as zero, paying just the base salary component.
Root cause
NULL + any number = NULL. The payment calculation produced NULL for employees without an approved bonus, and the downstream system treated NULL total_compensation as 'no payment required'.
Fix
Changed to base_salary + COALESCE(bonus_amount, 0). Added a pre-run data quality check: SELECT COUNT(*) FROM employees WHERE total_compensation IS NULL before any payment batch.
Key lesson
  • NULL arithmetic is NULL — any calculation involving NULL produces NULL, not zero
  • Always COALESCE numeric columns that can be NULL before performing arithmetic
  • Add data quality checks before any financial batch run: assert zero NULL values in critical payment columns
Production debug guideDiagnosing invisible rows, wrong aggregates, and arithmetic surprises3 entries
Symptom · 01
WHERE filter returns zero rows but data definitely exists
Fix
You are using = NULL. Run SELECT COUNT(*) FROM table WHERE col IS NULL to confirm NULL rows exist. Then use IS NULL for the filter. WHERE col = NULL always returns zero rows because NULL = NULL evaluates to UNKNOWN.
Symptom · 02
SUM or AVG is lower than expected
Fix
NULL values are excluded from SUM and AVG. Run SELECT COUNT(), COUNT(col), SUM(col) together — the difference between COUNT() and COUNT(col) is your NULL row count. Use COALESCE(col, 0) inside the aggregate if zero is the correct treatment.
Symptom · 03
Calculated column shows NULL for some rows unexpectedly
Fix
One of the input columns is NULL. NULL arithmetic propagates: price NULL = NULL, price + NULL = NULL. Find which column is NULL with: SELECT id, price, discount, price (1 - discount) FROM products WHERE price * (1 - discount) IS NULL.
AspectIS NULL / IS NOT NULLCOALESCENULLIF
PurposeTest whether a value is absentReplace NULL with a fallback valueTurn a specific value into NULL
ReturnsTRUE or FALSEFirst non-NULL value from the listNULL or the original value
Use in WHERE clauseYes — primary use caseYes — to filter on substituted valuesRarely — mainly in SELECT or arithmetic
Number of arguments1 (the column to test)2 or more (value then fallbacks)Exactly 2
Prevents errorsNo — detection onlyPrevents NULL propagation in mathPrevents divide-by-zero crashes
SQL standardYes — all major databasesYes — all major databasesYes — all major databases
MySQL-specific alternativeNone neededIFNULL(value, fallback)No direct alternative
⚙ Quick Reference
10 commands from this guide
FileCommand / CodePurpose
null_basics_check.sqlCREATE TABLE employees (What NULL Actually Is
coalesce_bonus_report.sqlSELECTCOALESCE
nullif_and_aggregates.sqlCREATE TABLE sales_performance (NULLIF and Aggregate Functions
FilteredIndexFix.sqlCREATE TABLE orders (The Nullability Tax
JoinLogicMeltdown.sqlCREATE TABLE customers (Three-Valued Logic
BonusNullDrift.sqlSELECTA Real-World Scenario
NullGuardPattern.sqlSELECTWrapping Up
null-vs-default.sqlCREATE TABLE test (NULL vs DBNULL vs DEFAULT
null-indexes.sqlCREATE INDEX idx_bonus ON employees(bonus);NULL in Indexes
null-functions.sqlSELECT COALESCE(bonus, commission, 0) AS payment FROM employees;COALESCE, NULLIF, ISNULL

Key takeaways

1
NULL means 'value unknown or absent'
it is NOT zero, NOT an empty string, and NOT false. It represents the total absence of information.
2
You can NEVER test for NULL with = or !=. Use IS NULL and IS NOT NULL exclusively
using = NULL silently returns zero rows with no error.
3
COALESCE(column, fallback) is your go-to tool for replacing NULL with a safe default
use it in SELECT to display fallbacks, in arithmetic to prevent NULL propagation, and in WHERE to filter on computed defaults.
4
Aggregate functions (SUM, AVG, MIN, MAX, COUNT(column)) all skip NULL rows automatically
COUNT(*) is the only one that counts every row regardless of NULLs. This distinction changes your totals and averages in ways that can silently break reports.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does WHERE column_name = NULL return no rows in SQL?
Q02JUNIOR
What does COALESCE do and when would you use it?
Q03JUNIOR
You have a query that calculates total compensation as base_salary + bon...
Q01 of 03JUNIOR

Why does WHERE column_name = NULL return no rows in SQL?

ANSWER
In SQL, NULL represents an unknown value. The comparison expression NULL = NULL evaluates to UNKNOWN (SQL uses three-valued logic: TRUE, FALSE, UNKNOWN) — not TRUE. The WHERE clause only includes rows where the condition evaluates to TRUE, so rows where the comparison evaluates to UNKNOWN are excluded. This is why = NULL never matches any row. The correct operators are IS NULL (returns TRUE when the value is NULL) and IS NOT NULL (returns TRUE when the value is not NULL). These are special operators specifically designed to test for the absence of a value, where = cannot be used.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Is NULL the same as an empty string or zero in SQL?
02
Why does NULL = NULL return false (or UNKNOWN) in SQL?
03
What happens when I use NULL in a JOIN condition?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Written from production experience, not tutorials.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's SQL Basics. Mark it forged?

7 min read · try the examples if you haven't

Previous
SQL UNION and INTERSECT
12 / 17 · SQL Basics
Next
SQL CASE Statement