SQL (PostgreSQL, MySQL, SQLite) Module 2 – Filtering and sorting rows
NULL and three-valued logic
What NULL means in SQL, why NULL = NULL is not true, how IS NULL and IS NOT DISTINCT FROM test for it, and how COALESCE and NULLIF handle missing values.
What you will learn
- Explain what NULL means and why NULL = NULL is not true
- Test for NULL with IS NULL, IS NOT NULL and null-safe comparisons
- Replace NULLs with COALESCE and avoid division errors with NULLIF
Before you start
On this page
Data is rarely complete. A customer skips the optional field, a sensor misses a reading, a rating was never given.
SQL marks such a gap with NULL: not a value, but the note “there is no value here”. This lesson explains how NULL
changes the rules of the lesson on WHERE, and the handful of tools that make queries behave when values are missing.
NULL means “not known”
NULL is not zero, not an empty string and not the word “NULL”. It says that a value is missing, and SQL treats a
missing value as unknown. That one idea explains its behaviour: ask whether an unknown value equals 7 and the honest
answer is “I don’t know”. Ask whether two unknown values are equal, and the answer is still “I don’t know”.
So a comparison with NULL is neither true nor false. Its result is a third truth value, unknown, which SQL
represents with NULL again:
-- quote() shows a missing value as the word NULL (the result grid shows an empty cell).
SELECT quote(NULL = NULL) AS eq,
quote(NULL <> NULL) AS ne,
quote(NULL IS NULL) AS is_null; Output
┌──────┬──────┬─────────┐ │ eq │ ne │ is_null │ ├──────┼──────┼─────────┤ │ NULL │ NULL │ 1 │ └──────┴──────┴─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < null_comparisons.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
quote() is a SQLite function that writes a value the way you would type it in SQL, so a missing value appears as
the word NULL. Without it, the result grid would show an empty cell, which looks the same as an empty string; this
page uses quote() whenever the difference matters. The first two columns are unknown: NULL = NULL is not true,
and NULL <> NULL is not true either. The last column uses the operator made for the job: IS NULL, like
IS NOT NULL, is always true or false.
Three-valued logic
With three truth values, AND, OR and NOT need a rule for “unknown”. The rule is common sense: if the known part
already settles the answer, the unknown part does not matter; otherwise the result is unknown too.
FALSE AND unknownis false, because one false side is enough to makeANDfalse.TRUE OR unknownis true, because one true side is enough to makeORtrue.TRUE AND unknown,FALSE OR unknownandNOT unknownstay unknown.
-- 1 is true, 0 is false and NULL is unknown.
CREATE TABLE pairs (a INTEGER, b INTEGER);
INSERT INTO pairs VALUES (1, 1), (1, 0), (1, NULL), (0, 0), (0, NULL), (NULL, NULL);
SELECT quote(a) AS a,
quote(b) AS b,
quote(a AND b) AS a_and_b,
quote(a OR b) AS a_or_b,
quote(NOT a) AS not_a
FROM pairs; Output
┌──────┬──────┬─────────┬────────┬───────┐ │ a │ b │ a_and_b │ a_or_b │ not_a │ ├──────┼──────┼─────────┼────────┼───────┤ │ 1 │ 1 │ 1 │ 1 │ 0 │ │ 1 │ 0 │ 0 │ 1 │ 0 │ │ 1 │ NULL │ NULL │ 1 │ 0 │ │ 0 │ 0 │ 0 │ 0 │ 1 │ │ 0 │ NULL │ 0 │ NULL │ 1 │ │ NULL │ NULL │ NULL │ NULL │ NULL │ └──────┴──────┴─────────┴────────┴───────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < truth_table.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
1 is true and 0 is false, as in the lesson on WHERE. The table is the same in PostgreSQL, MySQL and SQLite; only
the spelling differs, since PostgreSQL shows its boolean values as t and f.
What WHERE does with unknown
WHERE keeps a row only when its condition is true. A false condition drops the row, and so does an unknown one:
The three results of a condition, and what WHERE does with each
Text description of the diagram
A row of the table goes into the condition WHERE rating >= 4, which is worked out for that row. Three arrows lead to the three possible results.
- TRUE, for a row whose rating is 5: the row is kept.
- FALSE, for a row whose rating is 3: the row is dropped.
- UNKNOWN, for a row whose rating is NULL: the row is dropped as well. WHERE keeps only the rows for which the condition is TRUE.
The consequence is easy to miss. A condition and its opposite do not always cover every row between them:
CREATE TABLE reviews (
id INTEGER PRIMARY KEY,
product TEXT NOT NULL,
rating INTEGER -- 1 to 5; NULL when the buyer gave no rating
);
INSERT INTO reviews VALUES
(1, 'Ghee 500 ml', 5), (2, 'Atta 10 kg', NULL),
(3, 'Jaggery 1 kg', 3), (4, 'Poha 500 g', 4),
(5, 'Honey 250 g', NULL), (6, 'Rasam powder', 2);
SELECT count(*) AS all_rows FROM reviews;
SELECT count(*) AS four_or_more FROM reviews WHERE rating >= 4;
SELECT count(*) AS not_four_or_more FROM reviews WHERE NOT (rating >= 4);
SELECT count(*) AS unrated FROM reviews WHERE rating IS NULL; Output
┌──────────┐ │ all_rows │ ├──────────┤ │ 6 │ └──────────┘ ┌──────────────┐ │ four_or_more │ ├──────────────┤ │ 2 │ └──────────────┘ ┌──────────────────┐ │ not_four_or_more │ ├──────────────────┤ │ 2 │ └──────────────────┘ ┌─────────┐ │ unrated │ ├─────────┤ │ 2 │ └─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < unknown_rows.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Six reviews, two with four stars or more and two with fewer, which accounts for only four rows. The two reviews
without a rating are in neither group, because both rating >= 4 and NOT (rating >= 4) are unknown for them. When
a query must account for every row, add the missing ones explicitly: rating < 4 OR rating IS NULL.
The same thing happens with <>. WHERE coupon <> 'FESTIVE10' does not return the orders without a coupon, which
often surprises people who wanted “every order except those with this coupon”.
NOT IN and a NULL in the list
x NOT IN (a, b) is shorthand for x <> a AND x <> b. If one of the values in the list is NULL, one of those
comparisons is unknown for every row. AND can then be false, or unknown, but never true, so the query returns
nothing at all:
CREATE TABLE orders (id INTEGER PRIMARY KEY, coupon TEXT);
INSERT INTO orders VALUES (1, 'FESTIVE10'), (2, NULL), (3, 'WELCOME'), (4, 'FESTIVE10');
-- Orders that did not use the coupon FESTIVE10.
SELECT id FROM orders WHERE coupon NOT IN ('FESTIVE10');
-- The same, with a NULL that slipped into the list.
SELECT count(*) AS with_null_in_list FROM orders WHERE coupon NOT IN ('FESTIVE10', NULL); Output
┌────┐ │ id │ ├────┤ │ 3 │ └────┘ ┌───────────────────┐ │ with_null_in_list │ ├───────────────────┤ │ 0 │ └───────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < not_in_null.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The first query also shows the row-level effect: order 2 has no coupon, so NULL NOT IN ('FESTIVE10') is unknown,
and only order 3 is returned. In practice the NULL rarely appears in a typed list; it sneaks in from a subquery that
returns a list of values, which the subqueries module covers along with NOT EXISTS, the usual fix.
Testing for NULL
Because = NULL is never true, a query that tests for missing values with it finds nothing, without an error. This
is the most common NULL mistake:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
middle_name TEXT -- NULL: not known; '': known to have none
);
INSERT INTO customers VALUES
(1, 'Aditi', 'Rani'),
(2, 'Bilal', NULL),
(3, 'Chitra', ''),
(4, 'Dev', ' ');
-- The common mistake: "= NULL" is never true, so this counts nothing.
SELECT count(*) AS equals_null FROM customers WHERE middle_name = NULL;
SELECT first_name FROM customers WHERE middle_name IS NULL;
-- The grid shows NULL, '' and spaces alike; typeof() and quote() do not.
SELECT first_name,
middle_name,
typeof(middle_name) AS type,
quote(middle_name) AS quoted
FROM customers; Output
┌─────────────┐ │ equals_null │ ├─────────────┤ │ 0 │ └─────────────┘ ┌────────────┐ │ first_name │ ├────────────┤ │ Bilal │ └────────────┘ ┌────────────┬─────────────┬──────┬────────┐ │ first_name │ middle_name │ type │ quoted │ ├────────────┼─────────────┼──────┼────────┤ │ Aditi │ Rani │ text │ 'Rani' │ │ Bilal │ │ null │ NULL │ │ Chitra │ │ text │ '' │ │ Dev │ │ text │ ' ' │ └────────────┴─────────────┴──────┴────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < middle_names.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
middle_name = NULL counts nothing; middle_name IS NULL finds Bilal. The last table shows why an empty string must
not be confused with NULL. Chitra’s middle name is known to be empty, Bilal’s is not known, and Dev’s is three
spaces, yet the result grid shows all three the same way. typeof() reports the storage class (null or text) and
quote() makes the difference visible.
In PostgreSQL, MySQL and SQLite an empty string is a value like any other, and '' is never NULL. Oracle Database
is the well-known exception: it treats a string of length zero as null, which matters if you ever move data there.
Comparing values that may be missing
Sometimes you want NULL to count as an ordinary value: “has the city changed?” should be yes when it went from
unknown to Surat, and no when both are unknown. The standard SQL operators for that are IS DISTINCT FROM and
IS NOT DISTINCT FROM, which treat two NULLs as equal and never return unknown:
-- A customer's city before and after an address change; NULL means not known.
CREATE TABLE address_changes (id INTEGER PRIMARY KEY, old_city TEXT, new_city TEXT);
INSERT INTO address_changes VALUES
(1, 'Pune', 'Pune'), (2, 'Pune', 'Nagpur'), (3, NULL, 'Surat'),
(4, NULL, NULL), (5, 'Agra', NULL);
SELECT id,
quote(old_city) AS old_city,
quote(new_city) AS new_city,
quote(old_city <> new_city) AS not_equal,
old_city IS DISTINCT FROM new_city AS is_distinct,
old_city IS NOT new_city AS sqlite_is_not
FROM address_changes; Output
┌────┬──────────┬──────────┬───────────┬─────────────┬───────────────┐ │ id │ old_city │ new_city │ not_equal │ is_distinct │ sqlite_is_not │ ├────┼──────────┼──────────┼───────────┼─────────────┼───────────────┤ │ 1 │ 'Pune' │ 'Pune' │ 0 │ 0 │ 0 │ │ 2 │ 'Pune' │ 'Nagpur' │ 1 │ 1 │ 1 │ │ 3 │ NULL │ 'Surat' │ NULL │ 1 │ 1 │ │ 4 │ NULL │ NULL │ NULL │ 0 │ 0 │ │ 5 │ 'Agra' │ NULL │ NULL │ 1 │ 1 │ └────┴──────────┴──────────┴───────────┴─────────────┴───────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < null_safe.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
<> is unknown for rows 3, 4 and 5, so a WHERE old_city <> new_city would miss the changes in rows 3 and 5.
IS DISTINCT FROM gives the intended answer for every row. SQLite also has short forms: a IS b means
a IS NOT DISTINCT FROM b, and a IS NOT b means a IS DISTINCT FROM b. MySQL spells the null-safe equality
a <=> b, which its manual describes as the same as the standard IS NOT DISTINCT FROM.
Version note
IS DISTINCT FROM and IS NOT DISTINCT FROM arrived in SQLite 3.39.0, and older SQLite versions reject them; the
short forms IS and IS NOT work in older versions as well. PostgreSQL supports the standard spelling; MySQL 8.4
rejects it and uses <=> instead. The examples on this page ran with SQLite 3.49.1.
Replacing missing values: COALESCE and NULLIF
Two functions do most of the everyday work with missing values, and both are part of standard SQL:
coalesce(a, b, …)returns its first argument that is notNULL. It is the usual way to show a default:coalesce(note, '(no note)').nullif(a, b)returnsNULLwhenaequalsb, andaotherwise. It turns a stand-in value back into a missing one, such as an empty string or a zero.
Together they answer two common questions. Should an empty note count as no note? Then turn '' into NULL before
choosing the default. Will a division fail when the divisor is zero? Then divide by nullif(divisor, 0), and the
result is NULL instead:
CREATE TABLE pages (
page TEXT PRIMARY KEY,
visits INTEGER, -- NULL when the counter was broken
orders INTEGER NOT NULL,
note TEXT
);
INSERT INTO pages VALUES
('home', 1200, 36, 'festival banner'),
('offers', 0, 0, NULL),
('recipes', NULL, 5, '');
SELECT page,
coalesce(note, '(no note)') AS note_or_default,
coalesce(nullif(note, ''), '(no note)') AS blank_too,
quote(orders * 100.0 / nullif(visits, 0)) AS conversion_pct
FROM pages;
-- SQLite on its own: dividing by zero gives NULL, not an error.
SELECT quote(5 / 0) AS int_division, quote(5.0 / 0) AS real_division; Output
┌─────────┬─────────────────┬─────────────────┬────────────────┐ │ page │ note_or_default │ blank_too │ conversion_pct │ ├─────────┼─────────────────┼─────────────────┼────────────────┤ │ home │ festival banner │ festival banner │ 3.0 │ │ offers │ (no note) │ (no note) │ NULL │ │ recipes │ │ (no note) │ NULL │ └─────────┴─────────────────┴─────────────────┴────────────────┘ ┌──────────────┬───────────────┐ │ int_division │ real_division │ ├──────────────┼───────────────┤ │ NULL │ NULL │ └──────────────┴───────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < coalesce_nullif.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The recipes page shows the difference between the two coalesce columns: its note is an empty string, not NULL,
so only the version with nullif(note, '') shows the default. For offers, nullif(visits, 0) turns the zero into
NULL, and the conversion rate becomes unknown instead of a division by zero.
The last table shows why that matters when you move between engines. Division by zero gives NULL in SQLite. In
MySQL it also gives NULL, but with a warning under the default SQL mode of MySQL 8.4. PostgreSQL stops the whole
query with an error. nullif(divisor, 0) makes the intent explicit and gives the same answer in all three.
One more difference: PostgreSQL requires all the arguments of coalesce to be convertible to one common data type,
so a text default for a number column is an error there, while MySQL and SQLite accept a mix of types. Writing
defaults of the column’s own type, such as coalesce(visits, 0) for a number, keeps a query portable.
Choose a default with care, though. Aggregate functions such as avg() and count(column) skip NULLs, so the
average rating of the reviews above ignores the two unrated ones. avg(coalesce(rating, 0)) would count them as
zero stars and pull the average down, which is a different question. The aggregates module looks at this closely.
Key takeaways
NULLmeans a missing, unknown value; any ordinary comparison with it is unknown, evenNULL = NULL.WHEREkeeps only rows whose condition is true, so rows withNULLdrop out of both a condition and its opposite.- Test with
IS NULLandIS NOT NULL; compare values that may be missing withIS [NOT] DISTINCT FROM(MySQL:<=>). - An empty string is a value, not
NULL, in PostgreSQL, MySQL and SQLite. coalesce()supplies a default,nullif()turns a stand-in value intoNULL, andx / nullif(y, 0)avoids division-by-zero errors.- A
NULLin the list of aNOT INmakes the condition return no rows.
Exercise
Exercise · Easy · SQL
Find the orders placed without a coupon
The table orders has the columns id, customer and coupon, the code of the discount coupon used with the order. The checkout form saved "no coupon" in three different ways over the years:
NULL, when the field was left out;- an empty string,
''; - one or more spaces, such as
' '.
The starter query below was meant to find the orders placed without a coupon, but it returns no rows at all. Fix it so that it returns the id and customer of every order whose coupon is missing, empty or only spaces, and no order that has a real coupon code (a code with spaces around it, such as ' SAVE5 ', is a real code). The order of the rows does not matter.
Starter code · blank_coupons.sql
-- Meant to find the orders without a coupon, but it finds none.
SELECT id, customer
FROM orders
WHERE coupon = NULL; The sample tests · tests.yaml
# Sample tests: each one builds a small orders table, runs your query on it and compares the rows it returns
# (in any order).
tests:
- name: finds a missing, an empty and an all-space coupon
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, coupon TEXT);
INSERT INTO orders VALUES
(1, 'Asha', 'FESTIVE10'),
(2, 'Bilal', NULL),
(3, 'Chitra', ''),
(4, 'Dev', ' '),
(5, 'Esha', 'WELCOME');
expected:
columns: [id, customer]
rows:
- [2, Bilal]
- [3, Chitra]
- [4, Dev]
- name: keeps out real codes, also with spaces around them
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, coupon TEXT);
INSERT INTO orders VALUES
(1, 'Farah', 'SAVE5'),
(2, 'Gopal', ' SAVE5 '),
(3, 'Hema', 'FREESHIP');
expected:
columns: [id, customer]
rows: []
- name: returns every order when none has a coupon
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, coupon TEXT);
INSERT INTO orders VALUES
(1, 'Indu', NULL),
(2, 'Jai', ''),
(3, 'Kiran', NULL),
(4, 'Lata', ' ');
expected:
columns: [id, customer]
rows:
- [1, Indu]
- [2, Jai]
- [3, Kiran]
- [4, Lata] A hint
coupon = NULL is never true, because a comparison with NULL is unknown: test missing values with IS NULL. For the other two cases, trim(coupon) removes the spaces at both ends, so an empty or all-space coupon becomes ''. Join the two tests with OR.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- PostgreSQL 18: Comparison Functions and Operators (IS NULL, IS DISTINCT FROM) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Logical Operators (The PostgreSQL Global Development Group)
- PostgreSQL 18: Boolean Type (The PostgreSQL Global Development Group)
- PostgreSQL 18: Conditional Expressions (COALESCE, NULLIF) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Row and Array Comparisons (IN, NOT IN) (The PostgreSQL Global Development Group)
- PostgreSQL 18: The WHERE Clause (The PostgreSQL Global Development Group)
- NULL Handling in SQLite Versus Other Database Engines (SQLite)
- SQL Language Expressions (IS, IS DISTINCT FROM, IN) (SQLite)
- Built-In Scalar SQL Functions (coalesce, nullif, quote, typeof) (SQLite)
- Datatypes In SQLite (division by zero) (SQLite)
- SQLite Release History (SQLite)
- MySQL 8.4: Working with NULL Values (Oracle Corporation)
- MySQL 8.4: Problems with NULL Values (Oracle Corporation)
- MySQL 8.4: Comparison Functions and Operators (the <=> operator) (Oracle Corporation)
- MySQL 8.4: Server SQL Modes (ERROR_FOR_DIVISION_BY_ZERO) (Oracle Corporation)
- Oracle Database SQL Language Reference: Nulls (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress