Database Management Systems (DBMS) Module 5 – SQL semantics: how queries really evaluate
Three-valued logic: how SQL reasons about NULL
How three-valued logic treats NULL in WHERE, CHECK, CASE, DISTINCT, GROUP BY, UNIQUE, NOT IN and aggregates, with every truth table checked in SQLite.
What you will learn
- Evaluate comparisons, AND, OR and NOT when a value is UNKNOWN
- Identify the laws of two-valued logic that fail when a column holds NULL
- Predict NULL behaviour in WHERE, CHECK, DISTINCT, GROUP BY, UNIQUE, NOT IN and aggregates
- Use IS NULL, IS DISTINCT FROM, COALESCE and NOT NULL for the job each one does
Before you start
What you’ll learn · Before you start
What you will learn
- Evaluate comparisons, AND, OR and NOT when a value is UNKNOWN
- Identify the laws of two-valued logic that fail when a column holds NULL
- Predict NULL behaviour in WHERE, CHECK, DISTINCT, GROUP BY, UNIQUE, NOT IN and aggregates
- Use IS NULL, IS DISTINCT FROM, COALESCE and NOT NULL for the job each one does
Before you start
On this page
NULL marks a value that is missing, and a comparison with a missing value cannot be true or false: SQL gives it a
third truth value, UNKNOWN. With three values, AND, OR and NOT follow one compact rule. Order the values
FALSE < UNKNOWN < TRUE: AND returns the lower of its operands, OR the higher, and NOT swaps TRUE and FALSE but
leaves UNKNOWN as it is. The real work is done where a clause has to turn UNKNOWN into a decision, because the
clauses decide differently. WHERE, ON, HAVING and CASE WHEN treat UNKNOWN like FALSE, a CHECK constraint
treats it like TRUE, and DISTINCT, GROUP BY and the set operators treat two NULLs as the same value even though
NULL = NULL is UNKNOWN. Knowing which rule each place applies predicts almost every NULL surprise.
The SQL track’s NULL lesson drills IS NULL, COALESCE and NULLIF.
This lesson works out the logic underneath, with each table recorded in SQLite and the PostgreSQL rules from its
manual.
Three truth values and the order behind them
SQLite has no separate Boolean type: it stores TRUE as 1, FALSE as 0 and UNKNOWN as NULL. The query below builds
all nine pairs of truth values, computes AND, OR and NOT for each, and names the results, so that UNKNOWN shows
as a word rather than as an empty cell:
-- SQLite stores the three truth values as 1 (TRUE), 0 (FALSE) and NULL (UNKNOWN).
CREATE TABLE tv (t INTEGER, name TEXT, rank INTEGER);
INSERT INTO tv VALUES (0, 'FALSE', 0), (NULL, 'UNKNOWN', 1), (1, 'TRUE', 2);
-- All nine pairs (p, q). IS NOT DISTINCT FROM finds the name of a result even when it is NULL.
SELECT p.name AS p, q.name AS q, a.name AS "p AND q", o.name AS "p OR q", n.name AS "NOT p"
FROM tv AS p
CROSS JOIN tv AS q
JOIN tv AS a ON a.t IS NOT DISTINCT FROM (p.t AND q.t)
JOIN tv AS o ON o.t IS NOT DISTINCT FROM (p.t OR q.t)
JOIN tv AS n ON n.t IS NOT DISTINCT FROM (NOT p.t)
ORDER BY p.rank, q.rank;
-- Rank FALSE < UNKNOWN < TRUE: AND is the smaller rank and OR the larger, in every one of the nine pairs.
SELECT count(*) AS pairs,
sum(a.rank = min(p.rank, q.rank)) AS and_is_min,
sum(o.rank = max(p.rank, q.rank)) AS or_is_max
FROM tv AS p
CROSS JOIN tv AS q
JOIN tv AS a ON a.t IS NOT DISTINCT FROM (p.t AND q.t)
JOIN tv AS o ON o.t IS NOT DISTINCT FROM (p.t OR q.t); Output
┌─────────┬─────────┬─────────┬─────────┬─────────┐ │ p │ q │ p AND q │ p OR q │ NOT p │ ├─────────┼─────────┼─────────┼─────────┼─────────┤ │ FALSE │ FALSE │ FALSE │ FALSE │ TRUE │ │ FALSE │ UNKNOWN │ FALSE │ UNKNOWN │ TRUE │ │ FALSE │ TRUE │ FALSE │ TRUE │ TRUE │ │ UNKNOWN │ FALSE │ FALSE │ UNKNOWN │ UNKNOWN │ │ UNKNOWN │ UNKNOWN │ UNKNOWN │ UNKNOWN │ UNKNOWN │ │ UNKNOWN │ TRUE │ UNKNOWN │ TRUE │ UNKNOWN │ │ TRUE │ FALSE │ FALSE │ TRUE │ FALSE │ │ TRUE │ UNKNOWN │ UNKNOWN │ TRUE │ FALSE │ │ TRUE │ TRUE │ TRUE │ TRUE │ FALSE │ └─────────┴─────────┴─────────┴─────────┴─────────┘ ┌───────┬────────────┬───────────┐ │ pairs │ and_is_min │ or_is_max │ ├───────┼────────────┼───────────┤ │ 9 │ 9 │ 9 │ └───────┴────────────┴───────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < truth_tables.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The first table matches the truth tables in PostgreSQL’s manual for its boolean type
(PostgreSQL 18: logical operators). The second checks
the rule in the introduction on all nine pairs: give FALSE, UNKNOWN and TRUE the ranks 0, 1 and 2, and the rank of
p AND q is always the smaller of the two ranks, the rank of p OR q always the larger. Two consequences are worth
remembering:
- A known operand can settle the answer. FALSE
ANDanything is FALSE, and TRUEORanything is TRUE: FALSE is the lowest value and TRUE the highest. - UNKNOWN spreads otherwise. TRUE
ANDUNKNOWN is UNKNOWN, and so are FALSEORUNKNOWN andNOTUNKNOWN.
Comparisons are where UNKNOWN comes from. score > 40, score = 40 and score <> 40 are all UNKNOWN when score
is NULL, and so is NULL = NULL: two missing values are not known to be equal. Arithmetic passes NULL on the same
way, so score + 5 is NULL too.
Laws that stop holding
Two-valued logic has laws that SQL programmers lean on without noticing. Several of them fail as soon as a column holds NULL. Here are six test scores, two of them missing because those students were absent:
-- Six test scores; two students were absent, so their scores are unknown.
CREATE TABLE scores (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO scores VALUES (101, 72), (102, 35), (103, NULL), (104, 58), (105, NULL), (106, 40);
-- Conditions that two-valued logic would call always true. Each column counts the rows WHERE keeps.
SELECT
(SELECT count(*) FROM scores) AS all_rows,
(SELECT count(*) FROM scores WHERE score >= 40 OR NOT (score >= 40)) AS p_or_not_p,
(SELECT count(*) FROM scores WHERE score = score) AS x_equals_x,
(SELECT count(*) FROM scores WHERE score >= 40 OR score < 40) AS above_or_below;
-- A law that still holds: NOT (score >= 40) and score < 40 keep the same rows.
SELECT
(SELECT group_concat(roll, ' ') FROM scores WHERE NOT (score >= 40)) AS not_at_least_40,
(SELECT group_concat(roll, ' ') FROM scores WHERE score < 40) AS below_40; Output
┌──────────┬────────────┬────────────┬────────────────┐ │ all_rows │ p_or_not_p │ x_equals_x │ above_or_below │ ├──────────┼────────────┼────────────┼────────────────┤ │ 6 │ 4 │ 4 │ 4 │ └──────────┴────────────┴────────────┴────────────────┘ ┌─────────────────┬──────────┐ │ not_at_least_40 │ below_40 │ ├─────────────────┼──────────┤ │ 102 │ 102 │ └─────────────────┴──────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < broken_laws.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Each of the three conditions would keep all six rows in two-valued logic, and each keeps four:
p OR NOT pis UNKNOWN whenpis: the law of the excluded middle does not hold.score = scoreis UNKNOWN for a NULL score, so a value is not always equal to itself.score >= 40 OR score < 40misses the same two rows: a condition and its opposite do not cover the table.
Other laws survive. NOT (score >= 40) and score < 40 keep the same rows, because both are UNKNOWN for the absent
students, and De Morgan’s laws and double negation hold in three-valued logic as well. This is what limits an
optimiser. It may replace NOT (score >= 40) with score < 40, but it may not replace score = score with TRUE:
the equivalent condition is score IS NOT NULL.
The practical rule follows: when a query splits rows into groups by a condition and its negation, the rows where the condition is UNKNOWN form a third group, and someone has to decide where they belong.
Where UNKNOWN goes: each clause decides
A condition that can be UNKNOWN still has to produce a decision in the end, and SQL’s clauses resolve it in two opposite ways:
One condition, three results, two ways of deciding
Text description of the diagram
At the top is a condition on one row, score BETWEEN 0 AND 100. It is TRUE, FALSE, or UNKNOWN when score is NULL.
Below it are two panels, one above the other, with the same three results.
- In WHERE, ON, HAVING and CASE WHEN: a TRUE row is kept, a FALSE row is dropped, and an UNKNOWN row is dropped too.
- In a CHECK constraint: a TRUE row is accepted, a FALSE row is rejected, and an UNKNOWN row is accepted.
So a row whose score is NULL is stored by the CHECK constraint but filtered out by a WHERE with the same condition.
WHERE,ONandHAVING: a TRUE condition keeps the row or group; FALSE and UNKNOWN drop it.CASE WHEN: TRUE takes this branch; FALSE and UNKNOWN move on to the nextWHEN, or toELSE.- A
CHECKconstraint: FALSE rejects the row; TRUE and UNKNOWN both accept it.
WHERE keeps a row only when its condition is TRUE, and SQLite’s manual says rows are excluded when it is FALSE or
NULL (SQLite: SELECT). A CHECK constraint rejects a row only when its
condition is FALSE, so a NULL passes most checks: PostgreSQL’s manual says a check constraint is satisfied by TRUE or
the null value (PostgreSQL 18: constraints). The same
condition as a constraint and as a filter:
-- The same condition as a constraint and as a filter.
CREATE TABLE scores (roll INTEGER PRIMARY KEY, score INTEGER CHECK (score BETWEEN 0 AND 100));
INSERT INTO scores VALUES (101, 72);
INSERT INTO scores VALUES (102, NULL);
INSERT INTO scores VALUES (103, 120);
-- CHECK rejected only the row whose condition was FALSE; the UNKNOWN one is stored.
SELECT roll, quote(score) AS score FROM scores ORDER BY roll;
-- WHERE keeps only the rows whose condition is TRUE.
SELECT roll FROM scores WHERE score BETWEEN 0 AND 100;
-- IS NOT FALSE turns UNKNOWN into TRUE, so this WHERE decides the way CHECK did.
SELECT roll FROM scores WHERE (score BETWEEN 0 AND 100) IS NOT FALSE; Output (exit status 1)
┌──────┬───────┐ │ roll │ score │ ├──────┼───────┤ │ 101 │ 72 │ │ 102 │ NULL │ └──────┴───────┘ ┌──────┐ │ roll │ ├──────┤ │ 101 │ └──────┘ ┌──────┐ │ roll │ ├──────┤ │ 101 │ │ 102 │ └──────┘
Printed as an error (standard error)
Runtime error near line 5: CHECK constraint failed: score BETWEEN 0 AND 100
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < check_vs_where.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The CHECK stored student 102 with no score, because NULL BETWEEN 0 AND 100 is UNKNOWN, and rejected 120, for
which the condition is FALSE. The WHERE with the same condition then dropped student 102. If a score must be
present, the constraint that says so is NOT NULL: a check on the range of a value never requires the value to
exist.
When you want to choose the rule yourself, the Boolean tests make it explicit. cond IS TRUE is TRUE only for TRUE,
cond IS NOT FALSE is TRUE for TRUE and UNKNOWN, and cond IS UNKNOWN picks out the third value. They never return
UNKNOWN themselves (PostgreSQL 18: comparison operators).
SQLite accepts IS TRUE, IS NOT TRUE, IS FALSE and IS NOT FALSE, but not IS UNKNOWN
(SQLite: boolean expressions). The last query of the example uses
IS NOT FALSE to make a WHERE decide the way the CHECK did, keeping student 102.
When two NULLs count as the same
NULL = NULL is UNKNOWN, yet SELECT DISTINCT returns one NULL, not two. SQL compares values in two different
ways: equality with =, which follows three-valued logic, and distinctness, which is two-valued and treats
two NULLs as not distinct. DISTINCT compares rows with IS DISTINCT FROM in SQLite’s words, and the compound
operators consider NULL equal to NULL (SQLite: SELECT). Which constructs use
which comparison:
-- Cities of two groups of students; some cities were never filled in.
CREATE TABLE a (city TEXT);
CREATE TABLE b (city TEXT);
INSERT INTO a VALUES ('Pune'), (NULL), (NULL);
INSERT INTO b VALUES ('Pune'), (NULL);
-- How many rows each construct returns: the first four treat the NULLs as equal, = does not.
SELECT 'SELECT DISTINCT' AS construct, count(*) AS result_rows FROM (SELECT DISTINCT city FROM a)
UNION ALL SELECT 'GROUP BY', count(*) FROM (SELECT city FROM a GROUP BY city)
UNION ALL SELECT 'UNION', count(*) FROM (SELECT city FROM a UNION SELECT city FROM b)
UNION ALL SELECT 'INTERSECT', count(*) FROM (SELECT city FROM a INTERSECT SELECT city FROM b)
UNION ALL SELECT 'JOIN ON =', count(*) FROM a JOIN b ON a.city = b.city
UNION ALL SELECT 'JOIN ON IS NOT DISTINCT FROM', count(*) FROM a JOIN b ON a.city IS NOT DISTINCT FROM b.city;
-- A UNIQUE column accepts any number of NULLs, but not the same value twice.
CREATE TABLE contacts (roll INTEGER, email TEXT UNIQUE);
INSERT INTO contacts VALUES (101, NULL);
INSERT INTO contacts VALUES (102, NULL);
INSERT INTO contacts VALUES (103, 'kabir@example.com');
INSERT INTO contacts VALUES (104, 'kabir@example.com');
SELECT roll, quote(email) AS email FROM contacts ORDER BY roll; Output (exit status 1)
┌──────────────────────────────┬─────────────┐ │ construct │ result_rows │ ├──────────────────────────────┼─────────────┤ │ SELECT DISTINCT │ 2 │ │ GROUP BY │ 2 │ │ UNION │ 2 │ │ INTERSECT │ 2 │ │ JOIN ON = │ 1 │ │ JOIN ON IS NOT DISTINCT FROM │ 3 │ └──────────────────────────────┴─────────────┘ ┌──────┬─────────────────────┐ │ roll │ email │ ├──────┼─────────────────────┤ │ 101 │ NULL │ │ 102 │ NULL │ │ 103 │ 'kabir@example.com' │ └──────┴─────────────────────┘
Printed as an error (standard error)
Runtime error near line 20: UNIQUE constraint failed: contacts.email
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < null_as_equal.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Read the first table row by row. Table a holds Pune and two NULLs; table b holds Pune and one NULL.
SELECT DISTINCTandGROUP BYreturn 2 rows: Pune and a single NULL. All NULLs fall into one group.UNIONandINTERSECTreturn 2 rows too:INTERSECTfinds the NULL row in both tables and keeps it.JOIN ON a.city = b.cityreturns 1 row, Pune with Pune. The NULLs never join, because=is UNKNOWN for them.JOIN ON a.city IS NOT DISTINCT FROM b.cityreturns 3 rows: Pune with Pune, and each of the two NULLs inawith the NULL inb. This operator compares the wayDISTINCTdoes.
So INTERSECT and an inner join on = can disagree about the same two tables: the first keeps a row of NULLs, the
second never does.
UNIQUE is the exception. A UNIQUE column accepts any number of NULLs in SQLite and, by default, in PostgreSQL:
the second table shows two students without an email and the error from the repeated address. SQLite’s NULL page
calls this split between UNIQUE and DISTINCT puzzling but keeps it for compatibility
(SQLite: NULL handling). PostgreSQL 15 and later can make a unique constraint treat
NULLs as equal with UNIQUE NULLS NOT DISTINCT (PostgreSQL 15 release notes),
and its manual notes that the SQL standard leaves the default to each implementation
(PostgreSQL 18: constraints).
NULLs inside lists: IN and NOT IN
x IN (a, b) is shorthand for x = a OR x = b, and x NOT IN (a, b) for x <> a AND x <> b
(PostgreSQL 18: row and array comparisons).
Expanding the list shows what a NULL in it does:
-- x NOT IN (a, b) means x <> a AND x <> b; with a NULL in the list one conjunct is always UNKNOWN.
CREATE TABLE tv (t INTEGER, name TEXT);
INSERT INTO tv VALUES (1, 'TRUE'), (0, 'FALSE'), (NULL, 'UNKNOWN');
SELECT e.expression, tv.name AS result
FROM (
SELECT 1 AS k, '103 NOT IN (101, 102)' AS expression, 103 NOT IN (101, 102) AS v
UNION ALL SELECT 2, '103 NOT IN (101, NULL)', 103 NOT IN (101, NULL)
UNION ALL SELECT 3, '103 <> 101 AND 103 <> NULL', 103 <> 101 AND 103 <> NULL
UNION ALL SELECT 4, '101 NOT IN (101, NULL)', 101 NOT IN (101, NULL)
UNION ALL SELECT 5, 'NULL NOT IN (101, 102)', NULL NOT IN (101, 102)
UNION ALL SELECT 6, 'NULL IN (101, 102)', NULL IN (101, 102)
) AS e
JOIN tv ON tv.t IS NOT DISTINCT FROM e.v
ORDER BY e.k; Output
┌────────────────────────────┬─────────┐ │ expression │ result │ ├────────────────────────────┼─────────┤ │ 103 NOT IN (101, 102) │ TRUE │ │ 103 NOT IN (101, NULL) │ UNKNOWN │ │ 103 <> 101 AND 103 <> NULL │ UNKNOWN │ │ 101 NOT IN (101, NULL) │ FALSE │ │ NULL NOT IN (101, 102) │ UNKNOWN │ │ NULL IN (101, 102) │ UNKNOWN │ └────────────────────────────┴─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < not_in_list.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
103 NOT IN (101, NULL)expands to103 <> 101 AND 103 <> NULL, which is TRUEANDUNKNOWN: UNKNOWN.101 NOT IN (101, NULL)is FALSE, because101 <> 101is FALSE and FALSE decides anAND.
So with a NULL in the list, NOT IN is FALSE or UNKNOWN for every row and never TRUE: a WHERE x NOT IN (…)
returns nothing at all. A NULL on the left makes both IN and NOT IN UNKNOWN for any list that is not empty.
PostgreSQL’s manual states the same rules for IN and NOT IN with a subquery
(PostgreSQL 18: subquery expressions), which is where
the NULL usually comes from: a list of values read from a column. The
subqueries lesson replaces it with NOT EXISTS.
Aggregates skip NULLs, and NULLs sort at one end
Aggregate functions sidestep three-valued logic: they skip NULL inputs, and only count(*) counts rows:
CREATE TABLE scores (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO scores VALUES (101, 72), (102, 35), (103, NULL), (104, 58), (105, NULL), (106, 40);
-- Aggregates skip NULLs, except count(*), which counts rows.
SELECT count(*) AS rows_, count(score) AS scores, sum(score) AS total,
avg(score) AS average, sum(score) * 1.0 / count(*) AS per_student,
avg(coalesce(score, 0)) AS absent_as_zero
FROM scores;
-- Over no rows at all: count is 0, the others are NULL.
SELECT count(*) AS rows_, count(score) AS scores, quote(sum(score)) AS total, quote(avg(score)) AS average,
quote(max(score)) AS highest
FROM scores WHERE roll > 200; Output
┌───────┬────────┬───────┬─────────┬──────────────────┬──────────────────┐ │ rows_ │ scores │ total │ average │ per_student │ absent_as_zero │ ├───────┼────────┼───────┼─────────┼──────────────────┼──────────────────┤ │ 6 │ 4 │ 205 │ 51.25 │ 34.1666666666667 │ 34.1666666666667 │ └───────┴────────┴───────┴─────────┴──────────────────┴──────────────────┘ ┌───────┬────────┬───────┬─────────┬─────────┐ │ rows_ │ scores │ total │ average │ highest │ ├───────┼────────┼───────┼─────────┼─────────┤ │ 0 │ 0 │ 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: < aggregates_skip.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
count(*)is 6,count(score)is 4. The two absent students are rows, but not scores.avg(score)is 51.25, the total 205 divided by 4 scores, not by 6 students. Dividing bycount(*)gives 34.17, which is the average if absent students count as zero;avg(coalesce(score, 0))says that more clearly. Which one is right is a decision about the data, not about SQL.- Over no rows,
countreturns 0 andsum,avgandmaxreturn NULL. PostgreSQL’s manual makes the same point aboutsumof no rows (PostgreSQL 18: aggregate functions), and SQLite addstotal(), which returns 0.0 instead (SQLite: aggregate functions).
Sorting is the last place NULLs need a rule, and here the two engines differ. SQLite sorts NULL below every other value, so NULLs come first in ascending order (SQLite: SELECT); PostgreSQL sorts NULL as larger than every value, so they come last (PostgreSQL 18: sorting rows):
CREATE TABLE scores (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO scores VALUES (101, 72), (102, 35), (103, NULL), (104, 58);
-- SQLite sorts NULL below every other value; NULLS FIRST and NULLS LAST say where it goes.
SELECT
(SELECT group_concat(coalesce(score, 'NULL'), ' ') FROM (SELECT score FROM scores ORDER BY score)) AS ascending,
(SELECT group_concat(coalesce(score, 'NULL'), ' ') FROM (SELECT score FROM scores ORDER BY score DESC)) AS descending,
(SELECT group_concat(coalesce(score, 'NULL'), ' ') FROM (SELECT score FROM scores ORDER BY score NULLS LAST)) AS asc_nulls_last; Output
┌───────────────┬───────────────┬────────────────┐ │ ascending │ descending │ asc_nulls_last │ ├───────────────┼───────────────┼────────────────┤ │ NULL 35 58 72 │ 72 58 35 NULL │ 35 58 72 NULL │ └───────────────┴───────────────┴────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < null_order.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
NULLS FIRST and NULLS LAST fix the position in both engines; SQLite has accepted them since version 3.30.0
(SQLite: release history). Write one of them whenever the position of missing
values matters, such as a top-ten list where an unknown score must not come first.
Choosing the right tool
Each tool for NULLs answers a different question, and mixing them up causes most NULL bugs:
IS NULLandIS NOT NULLask whether a value is missing. They are always TRUE or FALSE.IS DISTINCT FROMandIS NOT DISTINCT FROMcompare two values that may be missing, the wayDISTINCTdoes: “has the email changed?” isold_email IS DISTINCT FROM new_email. SQLite has supported them since 3.39.0 and also spells themIS NOTandIS.COALESCE(x, default)replaces a missing value. It changes the question, as the zero-score average showed, so use it when you mean the default, not to make a NULL disappear.NOT NULLin the schema is the strongest fix: a value that cannot be missing never needs three-valued logic.
Interview questions
Fresher · A table has CHECK (score BETWEEN 0 AND 100). Does inserting a row with a NULL score succeed, and why
does the same condition behave differently in a WHERE clause?
The insert succeeds. NULL BETWEEN 0 AND 100 is UNKNOWN, and a CHECK constraint rejects a row only when its
condition is FALSE, so UNKNOWN passes. WHERE keeps a row only when its condition is TRUE, so the same row is
filtered out of SELECT … WHERE score BETWEEN 0 AND 100. Constraints give the row the benefit of the doubt; queries
do not. If a score must always be present, declare the column NOT NULL, or write
CHECK (score IS NOT NULL AND score BETWEEN 0 AND 100). A good answer also names the general rule: three-valued
logic gives UNKNOWN, and each clause decides what UNKNOWN means.
Key takeaways
- Comparisons with NULL are UNKNOWN. With FALSE < UNKNOWN < TRUE,
ANDis the minimum,ORthe maximum, andNOTleaves UNKNOWN unchanged. p OR NOT pandx = xare not always TRUE;NOT (x >= a)still equalsx < a.WHERE,ON,HAVINGandCASE WHENtreat UNKNOWN as FALSE;CHECKtreats it as TRUE, so a range check never requires a value: declare the columnNOT NULL.DISTINCT,GROUP BY,UNIONandINTERSECTtreat NULLs as equal;=does not;UNIQUEallows many NULLs unless PostgreSQL’sNULLS NOT DISTINCTsays otherwise.- A NULL in a
NOT INlist makes the condition FALSE or UNKNOWN for every row, so the query returns nothing. - Aggregates skip NULLs,
count(*)counts rows,sumof no rows is NULL, and SQLite sorts NULLs first while PostgreSQL sorts them last unlessNULLS FIRSTorNULLS LASTsays otherwise.
Exercise
Exercise · Easy · SQL
Label each result as pass, fail or absent
The table results(roll, score) holds one row per student who was registered for a test. A student who was absent has no score: score is NULL.
Return two columns, roll and label, with one row per student: 'pass' when the score is 40 or more, 'fail' when it is below 40, and 'absent' when there is no score. The order of the rows does not matter.
The starter query labels every absent student as a failure. Fix it.
Starter code · result_labels.sql
-- Meant to label every student, but an absent student falls through to ELSE.
SELECT roll,
CASE WHEN score >= 40 THEN 'pass' ELSE 'fail' END AS label
FROM results; The sample tests · tests.yaml
# Sample tests: each one builds a small results table, runs your query on it and compares the rows it returns
# (in any order).
tests:
- name: labels a pass, a fail and an absent student
setup: |
CREATE TABLE results (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO results VALUES (101, 72), (102, 35), (103, NULL);
expected:
columns: [roll, label]
rows:
- [101, pass]
- [102, fail]
- [103, absent]
- name: counts a score of exactly 40 as a pass and a zero as a fail
setup: |
CREATE TABLE results (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO results VALUES (201, 40), (202, 39), (203, 0);
expected:
columns: [roll, label]
rows:
- [201, pass]
- [202, fail]
- [203, fail]
- name: works when every student was absent
setup: |
CREATE TABLE results (roll INTEGER PRIMARY KEY, score INTEGER);
INSERT INTO results VALUES (301, NULL), (302, NULL);
expected:
columns: [roll, label]
rows:
- [301, absent]
- [302, absent] A hint
For a missing score, score >= 40 is UNKNOWN, and CASE WHEN treats UNKNOWN like FALSE, so the row falls through to ELSE. Test for the missing value first, with WHEN score IS NULL THEN 'absent', before the other two cases.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- PostgreSQL 18: Logical Operators (three-valued truth tables) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Comparison Functions and Operators (IS DISTINCT FROM, IS UNKNOWN) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Constraints (CHECK, UNIQUE, NULLS NOT DISTINCT) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Subquery Expressions (IN, NOT IN) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Row and Array Comparisons (IN and NOT IN with a list) (The PostgreSQL Global Development Group)
- PostgreSQL 15 release notes (UNIQUE NULLS NOT DISTINCT) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Aggregate Functions (The PostgreSQL Global Development Group)
- PostgreSQL 18: Sorting Rows (NULLS FIRST, NULLS LAST) (The PostgreSQL Global Development Group)
- NULL Handling in SQLite Versus Other Database Engines (SQLite)
- SQL Language Expressions (IS, IN, boolean expressions, CASE) (SQLite)
- The SELECT statement (WHERE, GROUP BY, DISTINCT, compound SELECT, ORDER BY) (SQLite)
- Built-in Aggregate Functions (SQLite)
- SQLite Release History (3.30.0 and 3.39.0) (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress