Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot

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:

Comparing with NULL SQL · null_comparisons.sql
-- 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

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 unknown is false, because one false side is enough to make AND false.
  • TRUE OR unknown is true, because one true side is enough to make OR true.
  • TRUE AND unknown, FALSE OR unknown and NOT unknown stay unknown.
AND, OR and NOT with unknown values SQL · truth_table.sql
-- 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

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:

WHERE rating >= 4 gives TRUE for a rating of 5 (kept), FALSE for 3 (dropped) and UNKNOWN for a missing rating (also dropped).One row of the tableWHERE rating >= 4is worked outfor that rowTRUErating 5:keptFALSErating 3:droppedUNKNOWNrating NULL:dropped too

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:

A condition and its opposite miss the unrated rows SQL · unknown_rows.sql
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

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:

NOT IN with and without a NULL in the list SQL · not_in_null.sql
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

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:

NULL, an empty string and spaces SQL · middle_names.sql
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

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:

Finding changed values when some are missing SQL · null_safe.sql
-- 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

<> 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 not NULL. It is the usual way to show a default: coalesce(note, '(no note)').
  • nullif(a, b) returns NULL when a equals b, and a otherwise. 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:

Defaults, empty strings and division by zero SQL · coalesce_nullif.sql
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

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.

SQLite Online (SQL Playground) Load a table with a few NULLs in the browser and try IS NULL, IS DISTINCT FROM and COALESCE on it.

Key takeaways

  • NULL means a missing, unknown value; any ordinary comparison with it is unknown, even NULL = NULL.
  • WHERE keeps only rows whose condition is true, so rows with NULL drop out of both a condition and its opposite.
  • Test with IS NULL and IS NOT NULL; compare values that may be missing with IS [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 into NULL, and x / nullif(y, 0) avoids division-by-zero errors.
  • A NULL in the list of a NOT IN makes 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.

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.

  1. Question 1 of 5 What does this query print?

    What does this program print? Choose one answer.

    -- 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;
    Show the answer to question 1

    Answer: it prints

    ┌──────┬──────┬─────────┐
    │  eq  │  ne  │ is_null │
    ├──────┼──────┼─────────┤
    │ NULL │ NULL │ 1       │
    └──────┴──────┴─────────┘
    

    Any ordinary comparison with a missing value is unknown, which SQL writes as NULL: even NULL = NULL, because two unknown values are not known to be equal. IS NULL and IS NOT NULL are made for missing values and are always true (1) or false (0).

  2. Question 2 of 5 A table of reviews has a rating column, and some reviews have no rating (NULL). Which reviews does WHERE rating <> 5 return?

    Choose one answer.

    Show the answer to question 2

    Answer: The reviews with a rating other than 5, but not the reviews without a rating

    For a review without a rating, NULL <> 5 is unknown, and WHERE keeps only rows whose condition is true. To include them, add OR rating IS NULL, or compare with rating IS DISTINCT FROM 5.

  3. Question 3 of 5 What does this query return in SQLite?

    Read the code, then choose one answer.

    SELECT coalesce(NULL, '', 'none');
    Show the answer to question 3

    Answer: An empty string

    coalesce() returns its first argument that is not NULL. An empty string is a value, not a missing one, so the search stops at the second argument. Use coalesce(nullif(x, ''), 'none') when an empty string should count as missing too.

  4. Question 4 of 5 How many rows does SELECT * FROM t WHERE x NOT IN (1, 2, NULL) return when t holds the values 3, 4 and 5 in column x?

    Choose one answer.

    Show the answer to question 4

    Answer: None

    x NOT IN (1, 2, NULL) means x <> 1 AND x <> 2 AND x <> NULL. The last comparison is unknown for every row, so the whole condition can be false or unknown but never true, and WHERE keeps nothing.

  5. Question 5 of 5 In SQLite, which of these are true (1) when both a and b are NULL?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • a IS b
    • a IS NOT DISTINCT FROM b

    IS NOT DISTINCT FROM treats two missing values as equal, and SQLite's IS is a short form of it. a = b is unknown when either side is NULL, and IS DISTINCT FROM is the opposite test, so it is false here. In MySQL the null-safe equality is written a <=> b.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.