PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 2: Querying a table

NULL and three-valued logic

Level 1: Foundations15 min

NULL means unknown, not zero or empty. Any comparison with NULL is itself unknown, and WHERE keeps only rows where the condition is true. That one rule explains most NULL surprises.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Predict the result of comparisons involving NULL
  • Use IS NULL, IS DISTINCT FROM, COALESCE, NULLIF and CASE
  • Explain how aggregates treat NULL

conceptThe concept

SQL uses three truth values: true, false and unknown (NULL). NULL = NULL is unknown, not true, and NOT unknown is still unknown. WHERE and JOIN conditions keep a row only when the condition is true, so rows where the comparison is unknown silently disappear.

Use IS NULL / IS NOT NULL to test for NULL, and IS DISTINCT FROM for a NULL-safe "not equal". COALESCE(a, b, ...) returns the first non-NULL argument; NULLIF(a, b) returns NULL when a equals b, which is handy to avoid division by zero.

Aggregates skip NULLs: count(col) counts non-NULL values while count(*) counts rows, and avg(col) divides by the non-NULL count. sum of no rows is NULL, not 0.

whyWhy it matters

A filter such as color <> 'red' quietly excludes products with no colour at all. In finance and compliance reports that is a wrong number, not a cosmetic issue.

Use caseReal-world scenario

In the shop, some products have no color attribute and the CEO has no manager. Both show up as NULL, and both trip up naive filters.

Try itExamples

Run and edit these in the interactive lesson.

Comparisons with NULL
SELECT NULL = NULL        AS equals,
       NULL IS NULL      AS is_null,
       NULL <> 1         AS not_equal,
       NULL IS DISTINCT FROM 1 AS distinct_from,
       NOT (NULL = 1)    AS negated;
Unknown is displayed as NULL. Only the IS forms return true or false.
Where rows go missing
SELECT count(*) AS all_products,
       count(attrs->>'color') AS with_color,
       count(*) FILTER (WHERE attrs->>'color' <> 'red') AS not_red_naive,
       count(*) FILTER (WHERE attrs->>'color' IS DISTINCT FROM 'red') AS not_red_correct
FROM products;
COALESCE, NULLIF and CASE
SELECT name,
       coalesce(attrs->>'color', 'unspecified') AS color,
       stock,
       round(price / NULLIF(stock, 0), 4) AS price_per_unit_in_stock,
       CASE WHEN stock = 0 THEN 'sold out'
            WHEN stock < 20 THEN 'low'
            ELSE 'ok' END AS stock_status
FROM products
ORDER BY stock
LIMIT 8;
The CEO has no manager
SELECT name, title, coalesce(manager_id::text, '(none)') AS manager
FROM employees
WHERE manager_id IS NULL OR manager_id = 1
ORDER BY id;

Avoid / preferDo this, not that

Avoid
SELECT name FROM employees WHERE manager_id = NULL;
Prefer
SELECT name FROM employees WHERE manager_id IS NULL;

Any comparison with NULL yields NULL, which WHERE treats as false, so the first query never returns a row.

Avoid
SELECT count(*) FROM products WHERE attrs ->> 'color' <> 'red';
Prefer
SELECT count(*) FROM products WHERE attrs ->> 'color' IS DISTINCT FROM 'red';

Products with no colour have NULL there, and NULL <> 'red' is NULL, so they silently disappear. IS DISTINCT FROM treats NULL as a comparable value.

PitfallsCommon mistakes

  • Writing WHERE col = NULL. It is never true; use IS NULL.
  • Using NOT IN (subquery) when the subquery can return NULL: the whole predicate becomes unknown and returns no rows. The subqueries lesson shows the safe NOT EXISTS form.
  • Assuming sum() over no rows is 0. Wrap it: coalesce(sum(x), 0).

LimitsWhen not to use it

Do not let NULL mean several things at once (unknown, not applicable, zero, not yet loaded). If the distinction matters to the business, model it explicitly with a status column or a separate table.

SecuritySecurity notes

Policies and permission checks that compare against a missing setting evaluate to NULL and hide rows, which is safe. Negated checks such as NOT (owner = current_user_id()) become NULL too and can behave unexpectedly; test policies with NULL inputs.

ChallengeTry it yourself

Count products by colour, showing no colour for products that have none, most common first.

Show one solution
One solution
SELECT coalesce(attrs ->> 'color', 'no colour') AS colour, count(*) AS products
FROM products
GROUP BY 1
ORDER BY 2 DESC, 1;

InterviewQuestions you should be able to answer

What is three-valued logic and how does it affect WHERE clauses?
Boolean expressions in SQL can be true, false or unknown (NULL). Any comparison with NULL yields unknown, AND/OR follow Kleene logic (false AND unknown is false, true OR unknown is true), and NOT unknown stays unknown. WHERE, JOIN ON and HAVING keep rows only when the condition is true, so unknown rows are dropped. CHECK constraints are the opposite: they pass unless the expression is false, so NULL passes a CHECK.
How do count(*), count(col) and count(DISTINCT col) differ?
count(*) counts rows. count(col) counts rows where col is not NULL. count(DISTINCT col) counts distinct non-NULL values. For averages, remember avg(col) ignores NULLs, so it is not the same as sum(col) / count(*).

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation