SQL NULL Handling Explained — IS NULL, COALESCE and Common Traps
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.
-- 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
(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
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(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
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
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(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
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
| Aspect | IS NULL / IS NOT NULL | COALESCE | NULLIF |
|---|---|---|---|
| Purpose | Test whether a value is absent | Replace NULL with a fallback value | Turn a specific value into NULL |
| Returns | TRUE or FALSE | First non-NULL value from the list | NULL or the original value |
| Use in WHERE clause | Yes — primary use case | Yes — to filter on substituted values | Rarely — mainly in SELECT or arithmetic |
| Number of arguments | 1 (the column to test) | 2 or more (value then fallbacks) | Exactly 2 |
| Prevents errors | No — detection only | Prevents NULL propagation in math | Prevents divide-by-zero crashes |
| SQL standard | Yes — all major databases | Yes — all major databases | Yes — all major databases |
| MySQL-specific alternative | None needed | IFNULL(value, fallback) | No direct alternative |
🎯 Key Takeaways
- NULL means 'value unknown or absent' — it is NOT zero, NOT an empty string, and NOT false. It represents the total absence of information.
- 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.
- 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.
- 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using = NULL in a WHERE clause — Symptom: the query returns zero rows silently, even though NULLs clearly exist in the column — Fix: always replace = NULL with IS NULL and replace != NULL or <> NULL with IS NOT NULL. SQL evaluates = NULL as UNKNOWN, which WHERE treats as false.
- ✕Mistake 2: Forgetting that NULL arithmetic poisons the whole result — Symptom: a calculated column like base_salary + annual_bonus returns NULL for the entire row when annual_bonus is NULL, even though base_salary is a perfectly valid number — Fix: wrap nullable columns in COALESCE before doing arithmetic: base_salary + COALESCE(annual_bonus, 0).
- ✕Mistake 3: Assuming COUNT() and COUNT(column) are the same — Symptom: a report shows an unexpected row count because COUNT(column_name) silently skips NULL values while COUNT() counts every row — Fix: be deliberate about which you use. Use COUNT(*) to count all rows, COUNT(column_name) to count rows where that column has a value, and COUNT(DISTINCT column_name) to count unique non-NULL values.
Interview Questions on This Topic
- QWhy does WHERE column_name = NULL return no rows in SQL, and how do you correctly filter for NULL values?
- QWhat is the difference between COUNT(*) and COUNT(column_name)? Give an example where they return different results.
- QYou have a query that calculates total compensation as base_salary + bonus. Some employees have no bonus yet. The results are showing NULL for total_compensation. How do you fix it, and can you explain why it happened?
Frequently Asked Questions
Is NULL the same as an empty string or zero in SQL?
No — NULL, empty string (''), and zero (0) are three completely different things in SQL. NULL means no value exists at all. An empty string is a value — it's just a string with no characters. Zero is a numeric value. A column can hold '' or 0 and those are not NULL. Use IS NULL to check for true absence of data.
Why does NULL = NULL return false (or UNKNOWN) in SQL?
Because NULL means 'unknown', and two unknowns are not necessarily the same unknown. SQL follows three-valued logic: TRUE, FALSE, and UNKNOWN. Comparing NULL to anything — including another NULL — produces UNKNOWN, not TRUE. This is by design in the SQL standard. To check if two columns are both NULL or both the same value, use IS NOT DISTINCT FROM in PostgreSQL, or a combination of IS NULL checks in other databases.
What happens when I use NULL in a JOIN condition?
NULLs in JOIN columns are silently dropped. Because NULL = NULL is UNKNOWN (not TRUE), any row where the join column contains NULL on either side will not match and won't appear in the result set. This is a common source of mysteriously missing rows in query results. If you need to match NULL-to-NULL in a join, use IS NOT DISTINCT FROM (PostgreSQL) or COALESCE the column to a sentinel value that can't appear in real data.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.