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
- What
NULLis and what it isn't - GreenStore's three
NULLs and what they mean - Three-valued logic:
TRUE,FALSEandUNKNOWN - The complete truth tables
IS NULLandIS NOT NULLIS DISTINCT FROMandIS NOT DISTINCT FROM- Where
NULLdoes group withNULL NULLand theUNIQUEconstraintNULLin concatenations and in arithmetic- Resolving the outstanding cases
- Designing with
NULL, with sentinels or withNOT NULL - Common Mistakes and Tips
- Exercises
- Conclusion
- What
NULL is and what it isn't
NULL is and what it isn'tNULL 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:
NULLdoesn't mean "empty", it means "unknown". And anything you compare with something unknown produces an unknown result.
- GreenStore's three
NULLs and what they mean
NULLs and what they meanIn 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 |
- Three-valued logic:
TRUE, FALSE and UNKNOWN
TRUE, FALSE and UNKNOWNOutside SQL, a condition is either true or false. In SQL there are three possible outcomes, because a comparison with NULL can't be decided:
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:
WHEREkeeps only the rows whose condition evaluates toTRUE. The ones that giveFALSEare discarded, and the ones that giveUNKNOWNare 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.
- 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 andstatus <> 'delivered'gives 6, and the table has 20, the logic closes becausestatusisNOT NULL. Withemployee_id = 4(4 rows) andemployee_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) > 2PostgreSQL could evaluate the division first. The safe form is CASE or NULLIF, both from module 6.
IS NULL and IS NOT NULL
IS NULL and IS NOT NULLSince = 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.
| 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
= NULLto behave likeIS NULL—SQL Server does it withSET 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.
IS DISTINCT FROM and IS NOT DISTINCT FROM
IS DISTINCT FROM and IS NOT DISTINCT FROMSometimes 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:
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 FROMis standard SQL and exists in PostgreSQL, SQLite and Oracle 23ai. MySQL uses the<=>operator ("null-safe equal"), which is equivalent toIS NOT DISTINCT FROM. SQL Server had nothing until 2022, when it introducedIS [NOT] DISTINCT FROM; in earlier versions you have to write the long form withOR ... IS NULL.
- Where
NULL does group with NULL
NULL does group with NULLHere 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:
| 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:
| 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):
| 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.
NULL and the UNIQUE constraint
NULL and the UNIQUE constraintA 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
UNIQUEcolumn; 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.
NULL in concatenations and in arithmetic
NULL in concatenations and in arithmeticThe 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.
- 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 <> 4 → UNKNOWN → 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.
- Designing with
NULL, with sentinels or with NOT NULL
NULL, with sentinels or with NOT NULLWhen 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:
- Is there a default value that's actually true? If so,
NOT NULL DEFAULT. Free shipping really is €0.00, not an absence. - Does the absence mean something to the business? If so,
NULL, and document it. "Web order" is valuable information, not a gap. - Are you tempted to use
-1,0,'N/A'or'9999-12-31'? Careful: that's a sentinel in disguise.AVGwill average it in,MINwill return it as the minimum andCOUNTwill count it. GreenStore would be a disaster if the web channel had been coded asemployee_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. UseNOT NULLwhenever you can justify a real default value; useNULLwhen 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
= NULLor<> NULL. Zero rows, always. It'sIS NULL/IS NOT NULL. - Believing
NULLis zero or the empty string.NULL + 5isNULL;0 + 5is5.NULL || 'a'isNULL;'' || 'a'is'a'. - Forgetting that
<>,NOT IN,NOT LIKEandNOT BETWEENdiscard null rows. Always check that the condition and its negation add up to the total. - Using
NOT INwith a list that may bring back aNULL. Zero rows, guaranteed (04-02). - Putting a condition on the right table in a
LEFT JOIN'sWHERE. The manufactured nulls don't pass it and theLEFTdegrades into anINNER(03-03). - Assuming that if
AisNULLthenNOT Ais true.NOT UNKNOWNisUNKNOWN. - Reading an empty cell in a report as a zero. It may be "not applicable".
COALESCE(06-04) makes it explicit. - Expecting
UNIQUEto prevent several null rows. It doesn't, except withNULLS NOT DISTINCTin 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. UseCASEorNULLIF(module 6). - Tip: when reading a schema, look first at which columns allow
NULL.\d tableinpsqltells 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 FROMby default when comparing two nullable columns. It saves you theOR ... IS NULLand 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:
- How many customers arrived on a recommendation and how many on their own? Write two queries and check that they add up to 15.
- 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.)
- 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 |
- What is that query really measuring, and why doesn't the 0 prove what he thinks it does?
- Write the version that genuinely counts the orders whose
employee_idis neither 4, nor 5, nor 6 (counting nulls as "it isn't any of the three"). How many are there? - Write the version that counts the orders with an
employee_idthat 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:
| referred_customers |
|---|
| 8 |
| 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)*:
- The
LEFT JOINfound no partner. For the seven customers with a nullreferred_by_id, the conditionc.referred_by_id = referrer.idgivesUNKNOWNand matches no row, so the engine fills all ofreferrer's columns withNULL(03-03, section 3). - Concatenation propagates the null. Even if only one of the two columns were null,
referrer.name || ' ' || referrer.last_namewould giveNULLall 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:
NULLis the absence of a value, not zero, not the empty string, not false, not equal to anotherNULL. 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 everyLEFT JOIN. - SQL uses three-valued logic:
TRUE,FALSEandUNKNOWN.WHERE,ONandHAVINGkeep only what'sTRUE, so unknown is discarded just like false — but under negation they behave differently, becauseNOT UNKNOWNis stillUNKNOWN. - The two cells to memorise are
TRUE OR UNKNOWN=TRUEandTRUE AND UNKNOWN=UNKNOWN. All the asymmetry betweenINandNOT INcomes from them. IS NULL/IS NOT NULLare predicates, not comparisons, and they do split the table into two exact halves.IS DISTINCT FROMcompares treating the null as just another value, and it's the right tool for comparing nullable columns.- The standard is deliberately inconsistent:
NULL = NULLis unknown, but inDISTINCT,GROUP BY,ORDER BYand the set operations the nulls are considered equal to each other. AUNIQUEconstraint, by contrast, considers them different and allows several. - The null propagates through arithmetic and concatenation:
price * NULLisNULL, not zero.COALESCE,NULLIFandCASE(06-04 and 06-05) are the tools for replacing it at presentation time. - The course's five outstanding cases —
= NULL,<> 4, theINNER JOINthat lost orders, theWHEREthat degraded theLEFT JOINand theNOT INwith a null— are the same case: a comparison that givesUNKNOWNand a row that disappears without warning. - When designing, choose consciously between
NULL, a sentinel value andNOT 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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
