Part 1 · 7 chapters · ~20 min

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.

6

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.

code
-- 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 WHERE cannot use a SELECT alias. WHERE runs at step 2; the alias is created at step 6.
  • Why ORDER BY can. It runs at step 8, after SELECT.
  • Why WHERE cannot contain an aggregate. Aggregates come from step 3; WHERE ran before the groups existed. That is what HAVING is for.
  • Why you cannot filter on a window function directly. Window functions are step 5, after WHERE and HAVING. You must wrap the query in a subquery or CTE.
  • Why SELECT DISTINCT with ORDER BY on a non-selected column fails. After DISTINCT at step 7, that column no longer exists to sort by.
“logical” is the important word
This is the order that defines the meaning. The engine is free to execute in any order that produces the same result — pushing predicates into the join, using an index to skip the sort, stopping early for LIMIT. Never confuse the semantic order with the physical plan. Part 9 and the MySQL module's Part 8 cover what actually happens.
run it
-- 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
logical evaluation order
written order vs evaluated order
swipe the figure sideways, or tap expand for full screen
1/9
written
A query, as you write it. SELECT comes first on the page.
7

The alias rule, stated precisely

A SELECT alias exists only for clauses evaluated after step 6. That gives a clean rule:

ClauseStepCan see a SELECT alias?
FROM / JOIN ... ON1No
WHERE2No
GROUP BY3MySQL yes, Postgres yes, standard no
HAVING4MySQL yes, standard no
SELECT6No — not even its own earlier aliases
ORDER BY8Yes — 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.

the portable workarounds
Repeat the expression, or use a CTE or subquery to name it once. A CTE is usually clearest and costs nothing when it is merged (MySQL module, ch 82):
WITH priced AS ( SELECT *, price * 1.2 AS gross FROM items ) SELECT * FROM priced WHERE gross > 100; -- now it is a real column
8

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.

TFU
TTFU
FFFF
UUFU

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.

the rule that matters most
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.
run it
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 wins
9

NULL 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.

ContextBehaviourNote
ComparisonNULL = anything → UNKNOWNUse IS NULL / IS NOT NULL.
ArithmeticNULL + 5 → NULLPropagates through every operator.
String concat'a' || NULL → NULLExcept Oracle, which treats it as empty string.
AggregatesIgnoredAVG(x) divides by the count of non-NULLs, not by row count. Materially different answers.
COUNT(*)Counts rowsThe one aggregate that does not skip NULLs, because it counts rows rather than values.
GROUP BYAll NULLs form one groupInconsistent with =, and deliberately so.
ORDER BYImplementation-definedMySQL sorts NULLs first ascending; Postgres last. NULLS FIRST/LAST is the portable fix.
UNIQUEMultiple NULLs allowedBecause they are not "equal" to each other. Catches people constantly.
DISTINCTNULLs are one valueAgain inconsistent with =, consistent with GROUP BY.

The functions for handling NULL

code
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
the design advice
Prefer 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.
run it
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 succeed
10

NULL 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:

code
-- 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

code
-- 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.
a real migration bug
Move a query from MySQL to Postgres and any 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.
11

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.

OperationDuplicatesCost
UNION ALLKeptCheap — concatenation, streams
UNIONRemovedSort or hash over the whole result
INTERSECT / EXCEPTRemovedSame; ALL variants keep them
SELECT DISTINCTRemovedSort or hash
GROUP BYCollapsedSame mechanism as DISTINCT
JoinMultipliesSee chapter 15 — the silent one
the habit worth building
Default to 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.
12

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.

code
-- 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:

run it
-- 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.
the trade
Keyset pagination gives constant-time pages and stable results under concurrent inserts — no row appears twice or gets skipped as data shifts, which 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.