The semantics nobody teaches
Most SQL is learned by pattern. This part replaces the patterns with rules. Two ideas do most of the work: SQL is evaluated in an order that is not the order it is written, and comparisons produce three outcomes rather than two. Nearly every SQL surprise you have had is one of those two, and after this part you will be able to name which.
The logical order of evaluation
You write SELECT first. The engine evaluates it sixth. This single
fact explains a cluster of behaviours that otherwise look arbitrary.
-- written -- evaluated SELECT ... 1. FROM + JOIN build the working set FROM ... 2. WHERE filter individual rows WHERE ... 3. GROUP BY collapse into groups GROUP BY ... 4. HAVING filter groups HAVING ... 5. window funcs computed over the result ORDER BY ... 6. SELECT project, assign aliases LIMIT ... 7. DISTINCT 8. ORDER BY aliases now exist 9. LIMIT / OFFSET
Everything this explains
- Why
WHEREcannot use aSELECTalias.WHEREruns at step 2; the alias is created at step 6. - Why
ORDER BYcan. It runs at step 8, afterSELECT. - Why
WHEREcannot contain an aggregate. Aggregates come from step 3;WHEREran before the groups existed. That is whatHAVINGis for. - Why you cannot filter on a window function directly. Window
functions are step 5, after
WHEREandHAVING. You must wrap the query in a subquery or CTE. - Why
SELECT DISTINCTwithORDER BYon a non-selected column fails. AfterDISTINCTat step 7, that column no longer exists to sort by.
LIMIT. Never confuse the semantic order with the physical plan.
Part 9 and the MySQL module's Part 8 cover what actually happens.-- alias in WHERE: fails. WHERE is step 2, alias is step 6. SELECT price * 1.2 AS gross FROM items WHERE gross > 100; -- ERROR -- alias in ORDER BY: works. ORDER BY is step 8. SELECT price * 1.2 AS gross FROM items ORDER BY gross; -- OK -- aggregate in WHERE: fails. groups do not exist yet. SELECT cat, COUNT(*) FROM items WHERE COUNT(*) > 5 GROUP BY cat; -- ERROR -- HAVING is the filter that runs AFTER grouping. SELECT cat, COUNT(*) FROM items GROUP BY cat HAVING COUNT(*) > 5; -- OK -- window function in WHERE: fails. windows are step 5. SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM items WHERE rn <= 10; -- ERROR -- wrap it so the window has already been computed. SELECT * FROM ( SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM items ) t WHERE rn <= 10; -- OK
The alias rule, stated precisely
A SELECT alias exists only for clauses evaluated after
step 6. That gives a clean rule:
| Clause | Step | Can see a SELECT alias? |
|---|---|---|
FROM / JOIN ... ON | 1 | No |
WHERE | 2 | No |
GROUP BY | 3 | MySQL yes, Postgres yes, standard no |
HAVING | 4 | MySQL yes, standard no |
SELECT | 6 | No — not even its own earlier aliases |
ORDER BY | 8 | Yes — everywhere |
The GROUP BY and HAVING rows are a genuine vendor
extension. MySQL and Postgres both accept aliases there for convenience even
though the standard does not. Relying on it is fine within one engine and a
portability hazard across them.
Three-valued logic
SQL predicates return TRUE, FALSE, or UNKNOWN. Any comparison involving NULL yields UNKNOWN, and UNKNOWN propagates through the operators with rules worth memorizing.
| T | F | U | |
|---|---|---|---|
| T | T | F | U |
| F | F | F | F |
| U | U | F | U |
Two cells carry most of the practical weight:
FALSE AND UNKNOWN = FALSE and
TRUE OR UNKNOWN = TRUE. The known value wins when
it can determine the outcome alone. Everywhere else, UNKNOWN wins.
WHERE, ON and HAVING keep a row only
when the predicate is TRUE. UNKNOWN is discarded exactly like
FALSE. And NOT UNKNOWN is still UNKNOWN — so negating a condition
does not return the rows it excluded.
That asymmetry means
WHERE x = 5 and WHERE x <> 5
together do not cover every row. Rows where x IS NULL appear in
neither.CREATE TABLE t (x INT);
INSERT INTO t VALUES (5), (7), (NULL);
SELECT COUNT(*) FROM t; -- 3
SELECT COUNT(*) FROM t WHERE x = 5; -- 1
SELECT COUNT(*) FROM t WHERE x <> 5; -- 1, not 2!
-- 1 + 1 = 2, but the table has 3 rows.
-- the NULL row satisfies NEITHER predicate.
SELECT COUNT(*) FROM t WHERE x = 5 OR x IS NULL; -- 2
-- and the three-valued result itself:
SELECT 5 = NULL AS eq, -- NULL
5 <> NULL AS ne, -- NULL
NOT(5 = NULL) AS n, -- NULL
(NULL AND FALSE) AS af, -- 0 ← FALSE wins
(NULL OR TRUE) AS ot; -- 1 ← TRUE winsNULL semantics
NULL is not a value. It is a marker meaning "no value here", and the rules for it differ by context in ways that are individually sensible and collectively confusing.
| Context | Behaviour | Note |
|---|---|---|
| Comparison | NULL = anything → UNKNOWN | Use IS NULL / IS NOT NULL. |
| Arithmetic | NULL + 5 → NULL | Propagates through every operator. |
| String concat | 'a' || NULL → NULL | Except Oracle, which treats it as empty string. |
| Aggregates | Ignored | AVG(x) divides by the count of non-NULLs, not by row count. Materially different answers. |
COUNT(*) | Counts rows | The one aggregate that does not skip NULLs, because it counts rows rather than values. |
GROUP BY | All NULLs form one group | Inconsistent with =, and deliberately so. |
ORDER BY | Implementation-defined | MySQL sorts NULLs first ascending; Postgres last. NULLS FIRST/LAST is the portable fix. |
UNIQUE | Multiple NULLs allowed | Because they are not "equal" to each other. Catches people constantly. |
DISTINCT | NULLs are one value | Again inconsistent with =, consistent with GROUP BY. |
The functions for handling NULL
COALESCE(a, b, c) -- first non-NULL. standard, variadic. NULLIF(a, b) -- NULL if a = b, else a. good for /0 guards. IFNULL(a, b) -- MySQL, two-arg COALESCE a IS NOT DISTINCT FROM b -- NULL-safe equality: NULL = NULL is TRUE a <=> b -- MySQL's spelling of the same thing
NOT NULL with a sentinel default where a value is genuinely
always present. Every nullable column is a permanent tax: every query touching
it must decide what NULL means, and the answer is rarely written down. Reserve
NULL for genuinely unknown or inapplicable data — which is what Codd
intended, and much rarer than most schemas suggest.CREATE TABLE s (v INT);
INSERT INTO s VALUES (10), (20), (NULL), (NULL);
SELECT COUNT(*) AS rows_, -- 4
COUNT(v) AS non_null, -- 2
SUM(v) AS total, -- 30
AVG(v) AS avg_, -- 15 (30/2, NOT 30/4)
SUM(v)/COUNT(*) AS avg_wrong -- 7.5 ← what people assume
FROM s;
-- GROUP BY puts all NULLs in ONE group...
SELECT v, COUNT(*) FROM s GROUP BY v; -- 3 rows: 10, 20, NULL(2)
-- ...but = never matches them.
SELECT COUNT(*) FROM s a JOIN s b ON a.v = b.v; -- 2, not 6
-- NULL-safe equality fixes that:
SELECT COUNT(*) FROM s a JOIN s b ON a.v <=> b.v; -- 6 (MySQL)
-- Postgres: ON a.v IS NOT DISTINCT FROM b.v
-- UNIQUE permits many NULLs:
CREATE TABLE u (e VARCHAR(50) UNIQUE);
INSERT INTO u VALUES (NULL), (NULL), (NULL); -- all succeedNULL in indexes, constraints and sorting
In indexes
MySQL and Postgres both store NULLs in B-tree indexes, so
WHERE x IS NULL can use an index. (Oracle famously does not for
single-column indexes, which is where the folk belief comes from.) A partial
index is the tool when NULLs dominate:
-- Postgres: index only the rows you query CREATE INDEX idx_active ON orders (customer_id) WHERE cancelled_at IS NULL; -- MySQL has no partial indexes; the equivalent is a -- generated column plus an index on it.
In UNIQUE constraints
The standard says NULLs do not conflict, so a UNIQUE column
accepts unlimited NULLs. This is the correct reading of "unknown values cannot
be shown to be equal" and it is also a frequent source of duplicate data.
Postgres 15 added UNIQUE NULLS NOT DISTINCT to opt into the other
behaviour. Elsewhere the workaround is a generated column mapping NULL to a
sentinel, indexed uniquely.
In sorting
-- implementation-defined by default: -- MySQL NULLs FIRST on ASC, LAST on DESC -- Postgres NULLs LAST on ASC, FIRST on DESC ← opposite -- portable and explicit (Postgres, Oracle, SQLite): ORDER BY shipped_at DESC NULLS LAST; -- MySQL has no NULLS LAST; sort on a computed flag first: ORDER BY (shipped_at IS NULL), shipped_at DESC; -- the boolean sorts 0 before 1, putting non-NULLs first.
ORDER BY nullable_col
without an explicit NULLS clause silently reverses where the NULL
rows appear. With a LIMIT on top, you get a different result set
entirely — and no error anywhere.Bags versus sets
A relation is a set; a SQL table is a bag (multiset). SQL therefore has to decide, at each operation, whether to deduplicate — and deduplication is expensive, requiring a sort or a hash.
| Operation | Duplicates | Cost |
|---|---|---|
UNION ALL | Kept | Cheap — concatenation, streams |
UNION | Removed | Sort or hash over the whole result |
INTERSECT / EXCEPT | Removed | Same; ALL variants keep them |
SELECT DISTINCT | Removed | Sort or hash |
GROUP BY | Collapsed | Same mechanism as DISTINCT |
| Join | Multiplies | See chapter 15 — the silent one |
UNION ALL and add UNION only when you have
a reason to expect duplicates and a reason to care. Writing UNION
reflexively imposes a sort over the entire result set — often the single
largest cost in a query, invisible because it looks like a keyword rather than
an operation.
The same applies to
SELECT DISTINCT. Reaching for it usually means
a join is multiplying rows (chapter 15), and removing the duplicates afterwards
treats the symptom while paying for a full deduplication.Row value expressions
An underused corner of SQL-92: tuples can be compared directly, and the comparison is lexicographic, exactly like comparing multi-part keys.
-- these are equivalent (a, b) > (3, 7) a > 3 OR (a = 3 AND b > 7) -- also valid (a, b) = (3, 7) (a, b) IN ((1,2), (3,4))
Why this matters: keyset pagination
OFFSET is O(n) — the engine produces and discards every skipped
row, so page 5000 is genuinely slow. Row values give the clean alternative,
and it maps perfectly onto a composite index:
-- SLOW: the engine generates and throws away 100,000 rows
SELECT id, created_at, title FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
-- FAST: seek straight to the position, constant time per page.
-- carry the last row's (created_at, id) from the previous page.
SELECT id, created_at, title FROM posts
WHERE (created_at, id) < ('2026-09-21 10:00:00', 48221)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- the index that makes it a single seek + 20-row walk:
CREATE INDEX idx_page ON posts (created_at DESC, id DESC);
-- MySQL 5.7 could not optimise row-value comparisons well;
-- 8.0 handles them properly. Verify with EXPLAIN that you
-- get a range scan rather than a full scan.OFFSET cannot promise. The cost is that you cannot jump to an
arbitrary page number. For feeds, infinite scroll and APIs that is not a loss;
for a numbered pager it is. Chapter 75 covers the hybrid.Part 2 applies these rules to joins, where three-valued logic and bag semantics combine to produce the most expensive mistakes in SQL.