This is the lesson where the debt gets paid. Since 01-03 you've been tripping over the same phenomenon in different disguises: employee_id = NULL returned zero rows when there were ten; employee_id <> 4 returned six instead of sixteen; the ten web orders vanished from an INNER JOIN; a condition on the right table in the WHERE degraded a LEFT JOIN into an INNER; and only yesterday, NOT IN (4, 5, NULL) returned exactly nothing. Every time we promised you it "gets explained in 04-03".

Here it is. All those cases are the same thing, and that thing is called three-valued logic. By the end of this lesson you won't have learned five new rules: you'll have learned one, and all those symptoms will stop surprising you because you'll be able to predict them. You'll also see the part almost nobody explains: that the SQL standard treats NULLs one way in the WHERE and a completely different way in DISTINCT, GROUP BY and ORDER BY, and that knowing that inconsistency is what separates those who suffer nulls from those who use them.

Contents

  1. What NULL is and what it isn't
  2. GreenStore's three NULLs and what they mean
  3. Three-valued logic: TRUE, FALSE and UNKNOWN
  4. The complete truth tables
  5. IS NULL and IS NOT NULL
  6. IS DISTINCT FROM and IS NOT DISTINCT FROM
  7. Where NULL does group with NULL
  8. NULL and the UNIQUE constraint
  9. NULL in concatenations and in arithmetic
  10. Resolving the outstanding cases
  11. Designing with NULL, with sentinels or with NOT NULL
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. What NULL is and what it isn't

NULL is the absence of a value. It isn't a special value: it's the marker saying there isn't one in that cell.

The most widespread confusion consists of treating it as if it were a concrete value. It isn't, and the difference shows as soon as you compare:

NULL is not Why it matters
Zero 0 is a number: it means "no units". NULL means "I don't know how many units". 0 + 5 = 5; NULL + 5 = NULL
The empty string '' '' is a string of length 0, a real value. LENGTH('') is 0; LENGTH(NULL) is NULL
FALSE FALSE is an answer. NULL is the lack of an answer
Another NULL Two absences aren't equal: you don't know what was in either of them

That last point is the heart of the whole thing. If there are two rows in products.cost with NULL, are their costs equal? You don't know. They could be €3.20 and €47. SQL, quite rightly, refuses to state that they're equal; and it also refuses to state that they're different.

The sentence that sums the lesson up: NULL doesn't mean "empty", it means "unknown". And anything you compare with something unknown produces an unknown result.

  1. GreenStore's three NULLs and what they mean

In a well-designed schema, every column that allows nulls does so for a specific business reason. GreenStore has three, and all three mean different things:

Column Rows with NULL What it means for the business
orders.employee_id 10 of 20 Order placed through the web, with no sales rep assigned. It isn't that the data was lost: it doesn't exist
customers.referred_by_id 7 of 15 A customer who arrived on their own, outside the referral programme
employees.manager_id 1 of 8 General management: Rosa Alcázar Vives has no manager because she's at the top of the org chart

Let's look at them.

SELECT id,
       customer_id,
       order_date,
       status,
       employee_id
FROM orders
WHERE employee_id IS NULL
ORDER BY id;
id customer_id order_date status employee_id
1 1 2025-03-04 delivered (null)
3 3 2025-04-02 delivered (null)
5 1 2025-05-07 delivered (null)
7 6 2025-06-11 delivered (null)
9 8 2025-07-15 delivered (null)
11 2 2025-09-09 delivered (null)
13 11 2025-10-22 delivered (null)
15 1 2025-12-02 delivered (null)
17 7 2026-01-13 shipped (null)
19 6 2026-02-09 paid (null)

10 rows, 50 % of the sales channel.

SELECT id,
       name || ' ' || last_name AS customer,
       signup_date,
       referred_by_id
FROM customers
WHERE referred_by_id IS NULL
ORDER BY id;
id customer signup_date referred_by_id
1 Lucía Martínez Soler 2025-01-10 (null)
4 Javier Ortega Ruiz 2025-02-14 (null)
6 Pau Llorens Vidal 2025-03-09 (null)
7 Sofia Moreira Costa 2025-03-21 (null)
9 Camille Dubois 2025-04-18 (null)
12 Diego Ramos Herrera 2025-06-01 (null)
14 Hugo Iglesias Pardo 2025-09-12 (null)

7 rows. The other 8 customers arrived on a recommendation.

SELECT id,
       name || ' ' || last_name AS employee,
       job_title,
       manager_id
FROM employees
WHERE manager_id IS NULL;
id employee job_title manager_id
1 Rosa Alcázar Vives General manager (null)

1 row. This NULL isn't an absent piece of data or an unknown one: it's a structural statement ("the hierarchy ends here"), and that's why 03-06's SELF JOIN needed a LEFT JOIN so as not to lose Rosa.

And there's still a fourth kind of NULL you've already seen and which isn't in any table: the ones the engine manufactures when building a LEFT JOIN (03-03, section 3). Those weren't in the data; they appear because there was no partner.

Origin of the NULL Example Is it on disk?
Data that doesn't exist (a business rule) The employee_id of a web order Yes
Unknown data A cost the supplier hasn't communicated Yes
A structural boundary The manager_id of general management Yes
Generated by a partnerless LEFT JOIN Núria Bosch's o.status No, the query creates it
The result of an operation involving nulls price * NULL No, the expression creates it

  1. Three-valued logic: TRUE, FALSE and UNKNOWN

Outside SQL, a condition is either true or false. In SQL there are three possible outcomes, because a comparison with NULL can't be decided:

5 = 5        → TRUE
5 = 4        → FALSE
5 = NULL     → UNKNOWN
NULL = NULL  → UNKNOWN
NULL <> NULL → UNKNOWN

UNKNOWN isn't some exotic third state: it's exactly what the word says. "Is order 1's employee number 4?" — we don't know, because there's no employee on record.

And now the rule that governs everything, already stated in 02-03 and worth repeating verbatim:

WHERE keeps only the rows whose condition evaluates to TRUE. The ones that give FALSE are discarded, and the ones that give UNKNOWN are too.

The same holds for a JOIN's ON and for the HAVING you'll see in 04-06. One criterion, three clauses.

flowchart TD
    A["Condition evaluated<br/>over a row"] --> B{"Result?"}
    B -->|"TRUE"| C["✅ The row passes"]
    B -->|"FALSE"| D["❌ The row is discarded"]
    B -->|"UNKNOWN"| E["❌ The row is discarded<br/>(just like FALSE)"]

There's the key to why false and unknown look the same when you're looking at a single query's result: both discard the row. The difference only becomes visible when you negate the condition, because NOT FALSE is TRUE but NOT UNKNOWN is still UNKNOWN.

  1. The complete truth tables

AND

AND TRUE FALSE UNKNOWN
TRUE TRUE FALSE UNKNOWN
FALSE FALSE FALSE FALSE
UNKNOWN UNKNOWN FALSE UNKNOWN

The two highlighted cells are the important ones: FALSE AND UNKNOWN is FALSE, not UNKNOWN. If one of the factors is already false, the whole thing is false even if the other is unknown. It doesn't matter what you don't know: the conjunction is already decided.

OR

OR TRUE FALSE UNKNOWN
TRUE TRUE TRUE TRUE
FALSE TRUE FALSE UNKNOWN
UNKNOWN TRUE UNKNOWN UNKNOWN

Symmetrically: TRUE OR UNKNOWN is TRUE. If one alternative is already true, the disjunction is true.

These two cells on their own explain 04-02's asymmetry:

Operator Expands to The deciding cell Result with a NULL in the list
IN Chained ORs TRUE OR UNKNOWN = TRUE Works normally
NOT IN Chained ANDs TRUE AND UNKNOWN = UNKNOWN Zero rows, always

NOT

Input NOT input
TRUE FALSE
FALSE TRUE
UNKNOWN UNKNOWN

Negating the unknown is still unknown. From this comes the most useful practical observation in the whole lesson:

A condition and its negation don't cover every row. If status = 'delivered' gives 14 rows and status <> 'delivered' gives 6, and the table has 20, the logic closes because status is NOT NULL. With employee_id = 4 (4 rows) and employee_id <> 4 (6 rows), 4 + 6 = 10 ≠ 20: the 10 missing ones are the nulls. That sum is your best null detector.

And a warning about evaluation order

The truth tables describe the meaning, not the order in which PostgreSQL evaluates the conditions. The engine may freely reorder an AND's operands if that makes the plan cheaper. That's why you can't use an AND as protection:

-- ⚠️ It does NOT guarantee that the division is never run with zero
WHERE stock <> 0 AND (100 / stock) > 2

PostgreSQL could evaluate the division first. The safe form is CASE or NULLIF, both from module 6.

  1. IS NULL and IS NOT NULL

Since = NULL is never TRUE, SQL offers a specific predicate. IS NULL isn't a comparison: it's a question about the cell's state, and that's why it always returns TRUE or FALSE, never UNKNOWN.

SELECT NULL = NULL      AS equality,
       NULL IS NULL     AS is_null,
       NULL IS NOT NULL AS is_not_null;
equality is_null is_not_null
(null) true false

The first column is NULL (that is, UNKNOWN); the other two are genuine booleans. That's the whole difference, and it explains why WHERE employee_id IS NULL works and WHERE employee_id = NULL doesn't.

Spelling Result type Does it return rows?
column = NULL UNKNOWN always Never
column <> NULL UNKNOWN always Never
column IS NULL TRUE / FALSE Yes, the null ones
column IS NOT NULL TRUE / FALSE Yes, the non-null ones

And the IS NULL / IS NOT NULL pair does split the table into two exact halves:

SELECT COUNT(*)                                       AS total,
       COUNT(*) FILTER (WHERE employee_id IS NULL)     AS without_rep,
       COUNT(*) FILTER (WHERE employee_id IS NOT NULL) AS with_rep
FROM orders;
total without_rep with_rep
20 10 10

10 + 10 = 20. It closes. (FILTER and COUNT are the next lesson's subject; here they're only acting as a measuring instrument.)

Dialect note: some engines let you configure = NULL to behave like IS NULL —SQL Server does it with SET ANSI_NULLS OFF, now deprecated. Never turn it on. It turns your SQL into something that only works on your server and breaks the logic you've just learned.

  1. IS DISTINCT FROM and IS NOT DISTINCT FROM

Sometimes you do want to compare treating NULL as "just another value": having two nulls count as equal and a null count as different from any value. That's what this pair of operators exists for, and they never return UNKNOWN.

Expression = / <> IS [NOT] DISTINCT FROM
5 = 5 / 5 IS NOT DISTINCT FROM 5 TRUE TRUE
5 = 4 / 5 IS NOT DISTINCT FROM 4 FALSE FALSE
5 = NULL / 5 IS NOT DISTINCT FROM NULL UNKNOWN FALSE
NULL = NULL / NULL IS NOT DISTINCT FROM NULL UNKNOWN TRUE

With real data, the difference jumps out. "All the orders Óscar Peris (employee 4) didn't handle":

-- ⚠️ INCORRECT for the question: it loses the web orders
SELECT id, customer_id, employee_id, status
FROM orders
WHERE employee_id <> 4
ORDER BY id;
id customer_id employee_id status
4 4 5 delivered
8 7 5 delivered
12 10 5 delivered
14 12 6 delivered
18 5 5 paid
20 9 6 pending

6 rows. It's the result that surprised you in 02-03.

-- ✅ CORRECT: an order with no sales rep wasn't handled by Óscar either
SELECT id, customer_id, employee_id, status
FROM orders
WHERE employee_id IS DISTINCT FROM 4
ORDER BY id;

16 rows: the previous 6 plus the 10 web orders. Now, at last, 4 + 16 = 20.

The equivalent long form would be WHERE employee_id <> 4 OR employee_id IS NULL, which is what you wrote by hand in solution 3 of 02-03. IS DISTINCT FROM says the same thing in four words with no risk of forgetting the second term.

Its most valuable use, though, appears when comparing two columns that can both be null:

-- Has the data changed between two versions of a row?
WHERE new.cost IS DISTINCT FROM old.cost

With <>, a row whose cost goes from NULL to 12.00 wouldn't be detected as a change (the comparison would give UNKNOWN). With IS DISTINCT FROM, it would. It's the reference operator for change detection, version comparison and synchronisation processes.

Dialect note: IS DISTINCT FROM is standard SQL and exists in PostgreSQL, SQLite and Oracle 23ai. MySQL uses the <=> operator ("null-safe equal"), which is equivalent to IS NOT DISTINCT FROM. SQL Server had nothing until 2022, when it introduced IS [NOT] DISTINCT FROM; in earlier versions you have to write the long form with OR ... IS NULL.

  1. Where NULL does group with NULL

Here comes the part that throws a lot of people: the SQL standard isn't consistent with itself. You've just learned that NULL = NULL is UNKNOWN, and yet:

SELECT DISTINCT employee_id
FROM orders
ORDER BY employee_id;
employee_id
4
5
6
(null)

4 rows. Ten orders have a null employee_id and DISTINCT has collapsed them into a single row. In other words: as far as DISTINCT is concerned, those ten nulls are equal to each other.

The same happens when grouping:

SELECT employee_id,
       COUNT(*) AS orders
FROM orders
GROUP BY employee_id
ORDER BY employee_id;
employee_id orders
4 4
5 4
6 2
(null) 10

4 groups, and the NULLs form one of their own with 10 rows. This is enormously useful —in fact it's what lets you count the web channel at a glance— and lesson 04-05 will develop it.

And when sorting, the nulls also cluster together, though their position is configurable (02-05):

SELECT id, employee_id, status
FROM orders
ORDER BY employee_id NULLS FIRST, id
LIMIT 12;
id employee_id status
1 (null) delivered
3 (null) delivered
5 (null) delivered
7 (null) delivered
9 (null) delivered
11 (null) delivered
13 (null) delivered
15 (null) delivered
17 (null) shipped
19 (null) paid
2 4 delivered
6 4 delivered

(First 12 of 20 rows.)

The table worth keeping to hand

Context Are two NULLs considered equal? Practical consequence
WHERE, ON, HAVING with = No The row is discarded
IS NOT DISTINCT FROM Yes Null-safe comparison
DISTINCT / DISTINCT ON Yes All the nulls collapse into one
GROUP BY Yes The nulls form one group
ORDER BY Yes They all go together (NULLS FIRST/LAST)
UNION, INTERSECT, EXCEPT Yes They deduplicate nulls like any other value
The UNIQUE constraint No (by default) Several nulls are allowed in the column
Aggregate functions They're ignored AVG averages only the non-null ones (04-04)
COUNT(*) They're counted It counts rows, not values

The standard's official justification is that WHERE answers questions about facts (and you can't state anything about an unknown), whereas GROUP BY and DISTINCT answer questions about grouping rows (and there "no value" is as valid a category as any other). It's a reasonable explanation, but it doesn't change the fact that the same symbol behaves in two ways. Learn the table; don't try to derive it.

  1. NULL and the UNIQUE constraint

A direct consequence of the penultimate row of that table: in PostgreSQL, a column with a UNIQUE constraint allows as many NULLs as you like, because two nulls aren't considered duplicates.

-- If products had a UNIQUE 'ean_code' column allowing nulls:
-- these two rows would coexist without a problem
INSERT INTO products (name, ean_code, ...) VALUES ('Product A', NULL, ...);
INSERT INTO products (name, ean_code, ...) VALUES ('Product B', NULL, ...);

This is surprising, and sometimes it's exactly what you want ("the EAN is unique, but not every product has one yet") and sometimes it isn't ("there can only be one unverified customer"). PostgreSQL 15 added a way to demand the opposite:

-- PostgreSQL 15+: NULLs count as duplicates of each other
ALTER TABLE products
  ADD CONSTRAINT products_ean_uk UNIQUE NULLS NOT DISTINCT (ean_code);

In GreenStore the two UNIQUE columns —categories.name and customers.email— are also NOT NULL, so the question never comes up. But it's a design detail to decide consciously, not to discover in production.

Dialect note: this is one of the points where engines diverge the most. PostgreSQL, MySQL, SQLite and Oracle allow several nulls in a UNIQUE column; SQL Server allows only one (it treats every null as the same value for the purposes of the unique index). A table that works in PostgreSQL can fail when migrated to SQL Server for this exact reason. Constraints are studied in module 5.

And remember that a primary key doesn't have this debate: PRIMARY KEY implies NOT NULL, always and on every engine.

  1. NULL in concatenations and in arithmetic

The rule is brutally simple: almost any operation involving NULL returns NULL. The null is said to propagate.

SELECT 100 + NULL       AS addition,
       100 * NULL       AS product,
       'Hello' || NULL  AS concatenation,
       UPPER(NULL)      AS uppercase;
addition product concatenation uppercase
(null) (null) (null) (null)

You saw it in 02-02 with concatenation, and in 03-03 with LEFT JOINs. Here's the reason: if you don't know what the second addend is, you can't know what the sum is.

Over real data, the effect in a LEFT JOIN:

SELECT c.id,
       c.name || ' ' || c.last_name AS customer,
       o.id AS order_id,
       o.shipping_cost,
       o.shipping_cost * 2 AS double_shipping
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE c.id IN (12, 13, 14, 15)
ORDER BY c.id, o.id;
id customer order_id shipping_cost double_shipping
12 Diego Ramos Herrera 14 4.95 9.90
13 Núria Bosch Ferrer (null) (null) (null)
14 Hugo Iglesias Pardo (null) (null) (null)
15 Inés Carrasco Vega (null) (null) (null)

double_shipping is NULL, not 0.00, for the three customers with no orders. In a report that can show up as an empty cell, and whoever reads it may take it for a zero. It isn't: it's "not applicable".

The exceptions

Not everything propagates the null. It's worth knowing the exceptions because they're precisely the tools for handling it:

Construct With NULL Comment
IS NULL / IS NOT NULL Returns a boolean This lesson's predicate
IS DISTINCT FROM Returns a boolean Section 6
COALESCE(a, b, c) Returns the first non-null 06-04
NULLIF(a, b) Turns a value into NULL 06-04
CASE WHEN ... IS NULL THEN ... Lets you decide 06-05
CONCAT('a', NULL, 'b') Ignores the nulls → 'ab' The alternative to ||, in 06-01
Aggregate functions They ignore nulls 04-04, the next lesson
COUNT(*) It counts the row anyway 04-04

You already handle the first three rows. The COALESCE, NULLIF and CASE ones —the ones that replace a null with something presentable— are the content of 06-04 and 06-05; there you'll write COALESCE(o.shipping_cost, 0) so the report's cell shows 0.00. And the aggregate one is the first thing you'll see tomorrow.

  1. Resolving the outstanding cases

Let's close, one by one, all the course's loose ends. All five have the same explanation.

Case 1: WHERE employee_id = NULL returned 0 rows (02-03)

employee_id = NULL evaluates to UNKNOWN for all twenty rows: for the ten with a value because you can't compare a number with the unknown, and for the ten null ones because NULL = NULL isn't true either. The WHERE discards everything that isn't TRUE. Zero rows, and it couldn't be otherwise. The correct form is IS NULL.

Case 2: WHERE employee_id <> 4 returned 6 and not 16 (02-03)

The ten web orders evaluate NULL <> 4UNKNOWN → discarded. That leaves the 10 with a sales rep, minus Óscar's 4: 6 rows. If the question was "the ones Óscar didn't handle", the correct answer has 16 rows and is written WHERE employee_id IS DISTINCT FROM 4, as in section 6.

Case 3: the INNER JOIN with employees lost 10 orders (03-02)

The condition ON o.employee_id = e.id is a comparison like any other. For the web orders it gives UNKNOWN, no row of employees satisfies it, and the INNER JOIN discards them. The ON follows the same rule as the WHERE.

Case 4: a condition in the WHERE degraded a LEFT JOIN into an INNER (03-03)

The LEFT JOIN manufactures a row for Núria with all of orders' columns at NULL. Then, WHERE o.status = 'delivered' evaluates NULL = 'delivered'UNKNOWN → discarded. That's why 18 rows became 14 and 15 customers became 12. The condition has to go in the ON, which acts before those nulls are manufactured.

Case 5: NOT IN (4, 5, NULL) returned 0 rows (04-02)

It expands to ... AND employee_id <> NULL, and that factor is UNKNOWN for every row. TRUE AND UNKNOWN = UNKNOWN (section 4's truth table). Zero rows guaranteed, for any table and any data.

The common pattern

flowchart LR
    A["A NULL enters<br/>a comparison"] --> B["The result is UNKNOWN"]
    B --> C["WHERE / ON / HAVING<br/>only let TRUE through"]
    C --> D["The row disappears<br/>with no error and no warning"]
    D --> E["🔍 Symptom: the filter and its<br/>negation don't add up to the total"]

The five cases are the same case. Once you see them that way, they stop being five rules to memorise and become one rule to apply.

  1. Designing with NULL, with sentinels or with NOT NULL

When it's your turn to design a table (module 5) you'll have to decide, column by column, whether it allows nulls. There are three strategies and none of them is universally right.

Strategy Example In favour Against
Allow NULL orders.employee_id It honestly models "there is no value". It invents no data. Aggregates ignore it on their own It forces you to handle three-valued logic in every query
A sentinel value A fictitious employee with id = 0 called "Web" Queries get simpler: ordinary JOINs, no LEFT, no IS NULL The sentinel is fake data: it turns up in counts, in DISTINCTs and in reports. You have to remember to exclude it every single time
NOT NULL with DEFAULT orders.shipping_cost NOT NULL DEFAULT 0 The column never surprises you. Summing is just summing It only works when a genuine default value exists. 0.00 of shipping is a fact; a cost of 0.00 would be a lie

Criteria for choosing:

  1. Is there a default value that's actually true? If so, NOT NULL DEFAULT. Free shipping really is €0.00, not an absence.
  2. Does the absence mean something to the business? If so, NULL, and document it. "Web order" is valuable information, not a gap.
  3. Are you tempted to use -1, 0, 'N/A' or '9999-12-31'? Careful: that's a sentinel in disguise. AVG will average it in, MIN will return it as the minimum and COUNT will count it. GreenStore would be a disaster if the web channel had been coded as employee_id = 0: any "average orders per sales rep" would be poisoned.

The sensible stance: NULLs are part of the relational model and hiding them behind sentinels doesn't remove the problem, it just makes it invisible. Use NOT NULL whenever you can justify a real default value; use NULL when the absence is legitimate; and don't use sentinels unless you have a very specific reason and you write it into the schema's documentation.

Common Mistakes and Tips

  • Writing = NULL or <> NULL. Zero rows, always. It's IS NULL / IS NOT NULL.
  • Believing NULL is zero or the empty string. NULL + 5 is NULL; 0 + 5 is 5. NULL || 'a' is NULL; '' || 'a' is 'a'.
  • Forgetting that <>, NOT IN, NOT LIKE and NOT BETWEEN discard null rows. Always check that the condition and its negation add up to the total.
  • Using NOT IN with a list that may bring back a NULL. Zero rows, guaranteed (04-02).
  • Putting a condition on the right table in a LEFT JOIN's WHERE. The manufactured nulls don't pass it and the LEFT degrades into an INNER (03-03).
  • Assuming that if A is NULL then NOT A is true. NOT UNKNOWN is UNKNOWN.
  • Reading an empty cell in a report as a zero. It may be "not applicable". COALESCE (06-04) makes it explicit.
  • Expecting UNIQUE to prevent several null rows. It doesn't, except with NULLS NOT DISTINCT in PostgreSQL 15+, or in SQL Server, which works the other way round.
  • Relying on an AND's evaluation order to protect against a null or a division by zero. The planner reorders. Use CASE or NULLIF (module 6).
  • Tip: when reading a schema, look first at which columns allow NULL. \d table in psql tells you. They're exactly the places where your queries can lose rows.
  • Tip: when a query returns fewer rows than expected, suspect nulls before the data. It's the most frequent cause and the quietest.
  • Tip: use IS DISTINCT FROM by default when comparing two nullable columns. It saves you the OR ... IS NULL and makes the intent explicit.

Exercises

Exercise 1

Without running anything, predict the result (true, false or *(null)*) of each expression. Then run them all in a single query and check.

SELECT NULL = NULL                    AS a,
       NULL IS NULL                   AS b,
       NULL <> NULL                   AS c,
       NOT (NULL = NULL)              AS d,
       (1 = 1) OR (NULL = 1)          AS e,
       (1 = 1) AND (NULL = 1)         AS f,
       (1 = 2) AND (NULL = 1)         AS g,
       NULL IS NOT DISTINCT FROM NULL AS h,
       5 IS DISTINCT FROM NULL        AS i;

Explain in one sentence each result that surprised you.

Exercise 2

The marketing department wants to measure the referral programme. Over customers:

  1. How many customers arrived on a recommendation and how many on their own? Write two queries and check that they add up to 15.
  2. Write the query that returns, for each customer, their full name and the full name of whoever recommended them, including those who weren't recommended by anybody. (Hint: a reflexive relationship, 03-06.)
  3. In that query, why does the referrer's column come out as *(null)* and not as an empty string? Give both reasons.

Exercise 3

A colleague has written this quality check and claims that "there are no odd orders in GreenStore":

-- ⚠️ INCORRECT
SELECT COUNT(*) AS orders_without_valid_rep
FROM orders
WHERE employee_id <> 4
  AND employee_id <> 5
  AND employee_id <> 6;
orders_without_valid_rep
0
  1. What is that query really measuring, and why doesn't the 0 prove what he thinks it does?
  2. Write the version that genuinely counts the orders whose employee_id is neither 4, nor 5, nor 6 (counting nulls as "it isn't any of the three"). How many are there?
  3. Write the version that counts the orders with an employee_id that is present but different from those three. How many are there and what does that number mean?

Solutions

Solution 1

Expression Result Reason
a: NULL = NULL (null) Two absences aren't comparable
b: NULL IS NULL true IS NULL isn't a comparison: it's a question about the state
c: NULL <> NULL (null) You can't state that they're different either
d: NOT (NULL = NULL) (null) NOT UNKNOWN = UNKNOWN
e: (1=1) OR (NULL=1) true TRUE OR UNKNOWN = TRUE. One true alternative is enough
f: (1=1) AND (NULL=1) (null) TRUE AND UNKNOWN = UNKNOWN. It's the NOT IN case
g: (1=2) AND (NULL=1) false FALSE AND UNKNOWN = FALSE. It's already decided
h: NULL IS NOT DISTINCT FROM NULL true This operator does treat nulls as equal
i: 5 IS DISTINCT FROM NULL true And it treats a value as different from a null

The three that usually cause surprise are d, f and g. d because you expect negating something to make it true; f and g because it looks contradictory that AND with an UNKNOWN sometimes gives UNKNOWN and sometimes FALSE — and yet it's the logical outcome: if one factor is already false, you don't need to know the other.

Solution 2

1. The two halves:

SELECT COUNT(*) AS referred_customers
FROM customers
WHERE referred_by_id IS NOT NULL;
referred_customers
8
SELECT COUNT(*) AS walk_in_customers
FROM customers
WHERE referred_by_id IS NULL;
walk_in_customers
7

8 + 7 = 15. It closes, because IS NULL and IS NOT NULL really do partition the table. If you'd written WHERE referred_by_id <> 0 for "the referred ones", you'd have got 8 anyway by pure chance, and WHERE referred_by_id = 0 would have given 0 instead of 7.

2. A self join with a LEFT JOIN:

SELECT c.id,
       c.name || ' ' || c.last_name AS customer,
       referrer.name || ' ' || referrer.last_name AS referred_by
FROM customers AS c
LEFT JOIN customers AS referrer ON c.referred_by_id = referrer.id
ORDER BY c.id;
id customer referred_by
1 Lucía Martínez Soler (null)
2 Carlos Ferrer Ibáñez Lucía Martínez Soler
3 Marta Sanchis Gil Lucía Martínez Soler
4 Javier Ortega Ruiz (null)
5 Ana Belmonte Roca Carlos Ferrer Ibáñez
6 Pau Llorens Vidal (null)
7 Sofia Moreira Costa (null)
8 Tiago Almeida Nunes Sofia Moreira Costa
9 Camille Dubois (null)
10 Julien Moreau Camille Dubois
11 Elena Navarro Puig Pau Llorens Vidal
12 Diego Ramos Herrera (null)
13 Núria Bosch Ferrer Ana Belmonte Roca
14 Hugo Iglesias Pardo (null)
15 Inés Carrasco Vega Lucía Martínez Soler

15 rows, all 15 customers. With an INNER JOIN there'd be 8 and you'd lose the walk-ins. Notice the detail: Lucía has recommended three customers (2, 3 and 15), which makes her GreenStore's best advocate.

3. The two reasons why referred_by comes out as *(null)*:

  1. The LEFT JOIN found no partner. For the seven customers with a null referred_by_id, the condition c.referred_by_id = referrer.id gives UNKNOWN and matches no row, so the engine fills all of referrer's columns with NULL (03-03, section 3).
  2. Concatenation propagates the null. Even if only one of the two columns were null, referrer.name || ' ' || referrer.last_name would give NULL all the same, because || with a null operand returns null (02-02 and section 9 of this lesson).

That it's *(null)* and not '' matters: the empty string would mean "that's their name, with zero characters". The null means "there's nobody there". For the report to show something readable —"No referrer", say— you need COALESCE, in 06-04.

Solution 3

1. What it's really measuring. The three factors are joined by AND, and for the ten web orders each one is UNKNOWN. UNKNOWN AND UNKNOWN AND UNKNOWN is UNKNOWN, so those ten rows are discarded. For the other ten, each order has an employee_id that is 4, 5 or 6, so one of the three factors is FALSE and the conjunction is FALSE.

Result: zero rows. And that 0 doesn't prove what he thinks, because the query is incapable of seeing half the table: the ten web orders never even get evaluated as candidates. If the criterion for "odd order" includes "no sales rep assigned", this query would never detect one, today or ever. A quality check that structurally can't come out positive isn't a quality check.

2. Counting nulls as "it isn't any of the three":

-- ✅ CORRECT
SELECT COUNT(*) AS orders_without_known_rep
FROM orders
WHERE employee_id IS DISTINCT FROM 4
  AND employee_id IS DISTINCT FROM 5
  AND employee_id IS DISTINCT FROM 6;
orders_without_known_rep
10

10 orders: exactly the ten from the web channel. IS DISTINCT FROM never returns UNKNOWN, so the conjunction resolves cleanly. The equivalent and more idiomatic form for this particular case would be:

SELECT COUNT(*) AS orders_without_known_rep
FROM orders
WHERE employee_id IS NULL
   OR employee_id NOT IN (4, 5, 6);

which returns the same 10.

3. Only the ones that have a sales rep and it isn't any of those three:

SELECT COUNT(*) AS orders_with_unknown_rep
FROM orders
WHERE employee_id IS NOT NULL
  AND employee_id NOT IN (4, 5, 6);
orders_with_unknown_rep
0

0 orders, and this zero does mean something: there's no order assigned to an employee other than Óscar, Laia or Marc. That is, the other five employees —Rosa, Andrés, Beatriz, Irene and Daniel— don't handle orders, exactly as 01-06 described.

The final check: 10 (with sales rep 4, 5 or 6) + 10 (with no sales rep) + 0 (with another sales rep) = 20. Now the logic closes, and that's the criterion for knowing the query is correctly written.

Conclusion

The debt is settled, and with a single idea:

  • NULL is the absence of a value, not zero, not the empty string, not false, not equal to another NULL. It means unknown.
  • In GreenStore there are three NULLs with three different business meanings —the web channel, the walk-in customer, the top of the org chart— plus the ones the engine manufactures in every LEFT JOIN.
  • SQL uses three-valued logic: TRUE, FALSE and UNKNOWN. WHERE, ON and HAVING keep only what's TRUE, so unknown is discarded just like false — but under negation they behave differently, because NOT UNKNOWN is still UNKNOWN.
  • The two cells to memorise are TRUE OR UNKNOWN = TRUE and TRUE AND UNKNOWN = UNKNOWN. All the asymmetry between IN and NOT IN comes from them.
  • IS NULL / IS NOT NULL are predicates, not comparisons, and they do split the table into two exact halves. IS DISTINCT FROM compares treating the null as just another value, and it's the right tool for comparing nullable columns.
  • The standard is deliberately inconsistent: NULL = NULL is unknown, but in DISTINCT, GROUP BY, ORDER BY and the set operations the nulls are considered equal to each other. A UNIQUE constraint, by contrast, considers them different and allows several.
  • The null propagates through arithmetic and concatenation: price * NULL is NULL, not zero. COALESCE, NULLIF and CASE (06-04 and 06-05) are the tools for replacing it at presentation time.
  • The course's five outstanding cases= NULL, <> 4, the INNER JOIN that lost orders, the WHERE that degraded the LEFT JOIN and the NOT IN with a null— are the same case: a comparison that gives UNKNOWN and a row that disappears without warning.
  • When designing, choose consciously between NULL, a sentinel value and NOT NULL DEFAULT. Sentinels don't remove the problem: they hide it inside the averages and the counts.

That's the end of the module's first half. You now know how to filter precisely: by pattern, by list, by range and by absence of value. In the next lesson, aggregate functions, the second half begins and the nature of what you do changes: until now every row of the result came from a row of the table; from now on many rows will go in and a single value will come out. You'll see COUNT, SUM, AVG, MIN and MAX, and the first thing you'll learn about them is that they all ignore NULLs —all but COUNT(*)— which makes orders.employee_id the best possible test bench: 20, 10 and 3 depending on how you count. And we'll finally resolve the warning repeated three times in module 3 about summing shipping costs after joining with the detail.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved