How SQL Reaches Bytes on Disk — Building a Query Engine From Scratch
Tracing a single SELECT statement all the way down — lexing, parsing, binding, logical planning, cardinality estimation, cost-based optimization, physical planning, Volcano execution, access methods, the buffer pool, slotted pages, and finally pread(2) — by writing the code that does it. Along the way: why sorting degrades gracefully past work_mem while hashing falls off a cliff, and why a parallel plan wants its Gather as high in the tree as possible.
What This Article Traces
Type SELECT name FROM users WHERE age > 30 and results come back. This article traces everything that happens in between, without ever stopping at "and from here it's a black box."
The starting point is a plain string. The end point is a concrete instruction: read 8192 bytes at offset 34,988,032 of file descriptor 42, and interpret 48 of those bytes as a struct. Between them sit more than a dozen transformations, each with its own intermediate representation.
The running example is a slightly larger query that joins two tables: SELECT u.name, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id WHERE u.age > 30 AND o.total > 100 ORDER BY o.total DESC LIMIT 10. The demo below follows it through every stage.
The Full Journey From One SQL Statement to Bytes
Follow a single query as it changes shape — text → tokens → AST → logical plan → physical plan → execution → page reads — with the intermediate representation shown at every stage.
SELECT u.name, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id WHERE u.age > 30 AND o.total > 100 ORDER BY o.total DESC LIMIT 10;
At this point it is just a string. To the database, neither users nor u.age means anything yet — they are only sequences of characters.
The overall structure looks like this. The upper half is the layer that understands the query; the lower half is the layer that touches data.
The dotted edges are references rather than data flow. The catalog and the statistics are consulted from outside the main top-to-bottom path. The plan cache skips part of that path: when the same SQL arrives a second time, a cached physical plan can be used right after binding, and the stages from the logical plan through optimization do not run again (chapter 9).
We will implement each stage in Python, leaning on PostgreSQL's vocabulary. Concrete names (pd_lower, t_hoff, clock-sweep) make it much easier to pick up the real source code afterwards.
The spine is the running example traced top to bottom, but we detour into GROUP BY aggregation, NULL and three-valued logic, what actually happens past work_mem, plan caching, and how parallel plans get costed. Each bears directly on why a query suddenly gets slow in production.
Index data structures themselves (B+ trees and LSM-trees) are covered in How Database Indexes Work, so this article concentrates on how indexes are used.
Show transcript
How does one SELECT statement travel all the way down to bytes on disk? From a string to pread(2), we'll follow the query engine layer by layer.
The starting point is plain SQL text. Lexing first cuts that string into tokens. The rule is maximal munch: >= is one token, and age30, with no space, is one identifier. Parsing then uses recursive descent and Pratt parsing to build an unresolved AST.
The AST still only represents what was written. It doesn't yet know whether a users table or an age column exists. Binding consults the catalog, and column names become positions (table and column numbers), PostgreSQL's Var nodes. After that, SQL syntax disappears, replaced by a logical plan of Scan, Filter, Join, and friends.
The first improvement is rewriting without changing meaning: split AND and push predicates down to scans. But with OR, NULL, and outer joins, the same transformation may no longer be safe. Next, row counts are estimated from statistics. The histogram gives age > 30 a selectivity of 0.55, or 55,000 of 100,000 rows.
Cost is not time; it is a relative score. One sequential page read is 1.0, then CPU and I/O are added. In the article's example, the same index varies 75-fold in heap I/O cost purely from how well index order matches physical order. For joins, nested loop, merge join, and hash join are compared, and in this example the hash join wins.
With three or more tables, join order matters even more than the join algorithm. System R style dynamic programming keeps only the best plan per subset, shrinking the state space to 2^n. In the article's example, the order as written costs 605,000, while the best order costs 15,000, roughly 40 times cheaper.
At this point, the logical plan commits to a concrete physical plan. This SELECT sequentially scans orders, builds a hash table from users, and joins them. Because of ORDER BY and LIMIT 10, the planner picks a Top-N heap sort instead of sorting everything.
Execution uses the Volcano model: every operator has open, next, and close. A next() call travels downward through the tree, and rows climb back upward. Blocking operators can't emit a first row early: Sort must drain its whole input, HashJoin its build side.
When work_mem is exceeded, different operators degrade in different ways. Sort switches to external merge and degrades gracefully, adding disk I/O on top of comparison cost. Hashing is harsher: it repartitions input into temporary files, so the cost can jump suddenly. In parallel plans, the key is placing Gather, which collects worker rows, as high in the plan tree as possible to reduce shipped rows.
Below the executor, access methods turn logical rows into physical locations. A PostgreSQL TID is a pair of block number and line-pointer number. Every page goes through the buffer pool: hits are reused, misses pick a victim with clock-sweep.
At the bottom, rows live inside 8192-byte slotted pages. Because a TID points to a slot, not a byte offset, a tuple can move within the page without its TID changing. Finally, reading block 4271 becomes pread on fd 42: 8192 bytes at offset 34,988,032.
SQL execution is a chain of transformations, from text to physical I/O. In the article, you can try demos for tokenizing, join order, Volcano execution, spilling, the buffer pool, and page layout.
Chapter 1: Lexing — Cutting a String Into Tokens
The first job is turning a sequence of characters into tokens, the smallest meaningful units.
It looks trivial, but decisions have to be made. Is >= one token or two? Is age30 an identifier, or age followed by 30? The answer is maximal munch: from the current position, consume as much as possible. That makes >= one token and age30 one identifier.
from dataclasses import dataclass
KEYWORDS = {
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT",
"JOIN", "ON", "AS", "ORDER", "BY", "ASC", "DESC", "LIMIT",
"GROUP", "HAVING",
}
TWO_CHAR_OPS = (">=", "<=", "<>", "!=")
@dataclass(frozen=True)
class Token:
kind: str # KEYWORD / IDENT / NUMBER / STRING / OP / PUNCT / EOF
text: str
pos: int
def tokenize(sql: str) -> list[Token]:
out: list[Token] = []
i, n = 0, len(sql)
while i < n:
if sql[i].isspace():
i += 1
continue
start, c = i, sql[i]
if c.isalpha() or c == "_":
while i < n and (sql[i].isalnum() or sql[i] == "_"):
i += 1
word = sql[start:i]
kind = "KEYWORD" if word.upper() in KEYWORDS else "IDENT"
out.append(Token(kind, word, start))
elif c.isdigit():
while i < n and sql[i].isdigit():
i += 1
# Only consume a dot when a digit follows it — otherwise we would
# break qualified references such as "t.1".
if i + 1 < n and sql[i] == "." and sql[i + 1].isdigit():
i += 1
while i < n and sql[i].isdigit():
i += 1
out.append(Token("NUMBER", sql[start:i], start))
elif c == "'":
i += 1
while i < n and sql[i] != "'":
i += 1
if i >= n:
raise SyntaxError("unterminated string literal")
i += 1 # closing quote
out.append(Token("STRING", sql[start:i], start))
elif sql[i:i + 2] in TWO_CHAR_OPS: # must be tried before 1-char ops
out.append(Token("OP", sql[i:i + 2], start))
i += 2
elif c in "=<>+-*/%":
out.append(Token("OP", c, start))
i += 1
elif c in ",().;":
out.append(Token("PUNCT", c, start))
i += 1
else:
raise SyntaxError(f"unexpected character {c!r} at position {i}")
out.append(Token("EOF", "", n))
return outThe order of the branches carries meaning. If the sql[i:i+2] in TWO_CHAR_OPS test does not come before the single-character operator test, >= splits into > and =.
The demo below runs this scanner one token at a time.
Running the Lexer, One Token at a Time
Scanning SELECT name, age FROM users WHERE age >= 30; left to right, tracing which rule produces which token.
Starts with a letter, so it is read with maximal munch; "SELECT" is in the keyword table, so it becomes a KEYWORD.
Where Keywords Are Recognized
The implementation above reads the whole word first and then consults the keyword table. Do it the other way around (scan for the literal string SELECT) and a column named selected_at splits into SELECT + ed_at. Munch first, classify second is the rule.
The SQL standard reserves more than 200 words, and implementations differ. PostgreSQL grades them into four categories, listed in kwlist.h: reserved; reserved but still usable as a function or type name; non-reserved but not usable as a function or type name; and fully non-reserved. That gradation is why words like value and name (both non-reserved) still work as column names.
Chapter 2: Parsing — Assembling Tokens Into a Tree
Next, the parser assembles the tokens into a tree. Plain recursive descent handles the skeleton of a statement (SELECT … FROM … WHERE …), but expressions (a + b * c > d AND e) need operator precedence. We use precedence climbing, also known as Pratt parsing.
AST Definitions
@dataclass
class ColRef: # an unresolved column reference
table: str | None
column: str
@dataclass
class Const:
value: object
@dataclass
class BinOp:
op: str
left: object
right: object
@dataclass
class FuncCall:
name: str
args: list
star: bool = False # the * in COUNT(*)
@dataclass
class TableRef:
name: str
alias: str | None
@dataclass
class JoinExpr:
left: object
right: object
on: object
@dataclass
class SortKey:
expr: object
desc: bool
@dataclass
class Select:
targets: list
frm: object
where: object | None
group_by: list
having: object | None
order_by: list
limit: int | NoneThe Parser
PRECEDENCE = {
"OR": 1,
"AND": 2,
"=": 3, "<>": 3, "!=": 3, "<": 3, ">": 3, "<=": 3, ">=": 3,
"+": 4, "-": 4,
"*": 5, "/": 5,
}
class Parser:
def __init__(self, tokens: list[Token]):
self.toks, self.i = tokens, 0
def peek(self) -> Token:
return self.toks[self.i]
def advance(self) -> Token:
tok = self.toks[self.i]
self.i += 1
return tok
def accept(self, text: str) -> Token | None:
if self.peek().text.upper() == text.upper():
return self.advance()
return None
def expect(self, text: str) -> Token:
tok = self.accept(text)
if tok is None:
raise SyntaxError(f"expected {text!r}, found {self.peek().text!r}")
return tok
# ── statement skeleton: recursive descent ─────────
def parse_select(self) -> Select:
self.expect("SELECT")
targets = [self.parse_expr()]
while self.accept(","):
targets.append(self.parse_expr())
self.expect("FROM")
frm = self.parse_from()
where = self.parse_expr() if self.accept("WHERE") else None
group_by = []
if self.accept("GROUP"):
self.expect("BY")
group_by.append(self.parse_expr())
while self.accept(","):
group_by.append(self.parse_expr())
having = self.parse_expr() if self.accept("HAVING") else None
order_by = []
if self.accept("ORDER"):
self.expect("BY")
while True:
expr = self.parse_expr()
desc = self.accept("DESC") is not None
if not desc:
self.accept("ASC")
order_by.append(SortKey(expr, desc))
if not self.accept(","):
break
limit = int(self.advance().text) if self.accept("LIMIT") else None
return Select(targets, frm, where, group_by, having, order_by, limit)
def parse_from(self):
node = self.parse_table_ref()
while self.accept("JOIN"):
right = self.parse_table_ref()
self.expect("ON")
node = JoinExpr(node, right, self.parse_expr())
return node
def parse_table_ref(self) -> TableRef:
name = self.advance().text
if self.accept("AS"):
return TableRef(name, self.advance().text)
if self.peek().kind == "IDENT": # AS-less alias
return TableRef(name, self.advance().text)
return TableRef(name, None)
# ── expressions: precedence climbing ──────────────
def parse_expr(self, min_prec: int = 0):
left = self.parse_primary()
while True:
op = self.peek().text.upper()
prec = PRECEDENCE.get(op, -1)
if prec < 0 or prec < min_prec:
return left
self.advance()
# Left associative: the right operand may only absorb
# operators that bind more tightly than this one.
right = self.parse_expr(prec + 1)
left = BinOp(op, left, right)
def parse_primary(self):
tok = self.advance()
if tok.kind == "NUMBER":
return Const(float(tok.text) if "." in tok.text else int(tok.text))
if tok.kind == "STRING":
return Const(tok.text[1:-1])
if tok.kind == "IDENT":
if self.peek().text == "(": # an aggregate function call
self.advance()
if self.accept("*"):
self.expect(")")
return FuncCall(tok.text.upper(), [], star=True)
args = [self.parse_expr()]
while self.accept(","):
args.append(self.parse_expr())
self.expect(")")
return FuncCall(tok.text.upper(), args)
if self.peek().text == ".":
self.advance()
return ColRef(tok.text, self.advance().text)
return ColRef(None, tok.text)
if tok.text == "(":
inner = self.parse_expr()
self.expect(")")
return inner
raise SyntaxError(f"unexpected token {tok.text!r}")The + 1 in parse_expr(prec + 1) is what creates left associativity. When the parser reaches a - b - c, it parses the right operand of the first - with min_prec = 5, so the second - (precedence 4) is not absorbed and we get (a - b) - c. Write parse_expr(prec) instead and you get right associativity, a - (b - c), which changes the result. Right-associative operators such as exponentiation use exactly that form.
An AST Is Only What Was Written
What we have now is a syntax tree. Whether a table named users exists, whether it has a column age, whether the comparison age > 30 is well typed — none of that has been checked. SELECT nonexistent FROM nowhere parses without complaint.
Chapter 3: Binding — Attaching Names to Entities
Meaning is attached during binding (also called semantic analysis), the first stage that consults the catalog, i.e. the system tables.
The Catalog
@dataclass
class Column:
name: str
type: str # "int" / "text" / "numeric"
@dataclass
class Relation:
name: str
columns: list[Column]
n_rows: int # analogous to pg_class.reltuples
n_pages: int # analogous to pg_class.relpages
oid: int = 0 # pg_class.oid
relfilenode: int = 0 # on-disk file name; changes on VACUUM FULL etc.
CATALOG = {
"users": Relation(
"users",
[Column("id", "int"), Column("name", "text"), Column("age", "int")],
n_rows=100_000, n_pages=1_000, oid=16384, relfilenode=16384,
),
"orders": Relation(
"orders",
[Column("id", "int"), Column("user_id", "int"), Column("total", "numeric")],
n_rows=1_000_000, n_pages=10_000, oid=16390, relfilenode=16390,
),
}Range Tables and Vars
After binding, a column reference is a position, not a name: PostgreSQL's Var(varno, varattno). varno indexes the range table (the list of relations that appeared in FROM), and varattno is the column number.
@dataclass(frozen=True)
class Var:
rel: int # index into the range table
attno: int # column number (0-based here; PostgreSQL is 1-based)
type: str
label: str # for display only; never used at run time
COMPARISONS = {"=", "<>", "!=", "<", ">", "<=", ">="}
NUMERIC_TYPES = {"int", "numeric"}
class Binder:
def __init__(self, catalog):
self.catalog = catalog
self.range_table: list[tuple[str, Relation]] = []
def collect_from(self, node) -> None:
if isinstance(node, TableRef):
if node.name not in self.catalog:
raise NameError(f'relation "{node.name}" does not exist')
rel = self.catalog[node.name]
self.range_table.append((node.alias or node.name, rel))
elif isinstance(node, JoinExpr):
self.collect_from(node.left)
self.collect_from(node.right)
else:
raise TypeError(node)
def bind_expr(self, expr) -> tuple[object, str]:
if isinstance(expr, Const):
if isinstance(expr.value, bool):
return expr, "bool"
if isinstance(expr.value, int):
return expr, "int"
if isinstance(expr.value, float):
return expr, "numeric"
return expr, "text"
if isinstance(expr, ColRef):
hits = []
for rel_idx, (alias, rel) in enumerate(self.range_table):
if expr.table is not None and expr.table != alias:
continue
for attno, col in enumerate(rel.columns):
if col.name == expr.column:
hits.append(Var(rel_idx, attno, col.type,
f"{alias}.{col.name}"))
if not hits:
raise NameError(f'column "{expr.column}" does not exist')
if len(hits) > 1:
raise NameError(f'column reference "{expr.column}" is ambiguous')
return hits[0], hits[0].type
if isinstance(expr, BinOp):
left, lt = self.bind_expr(expr.left)
right, rt = self.bind_expr(expr.right)
if expr.op in ("AND", "OR"):
if (lt, rt) != ("bool", "bool"):
raise TypeError(f"argument of {expr.op} must be boolean")
return BinOp(expr.op, left, right), "bool"
if lt != rt and not {lt, rt} <= NUMERIC_TYPES:
raise TypeError(
f"operator does not exist: {lt} {expr.op} {rt}")
result = "bool" if expr.op in COMPARISONS else lt
return BinOp(expr.op, left, right), result
if isinstance(expr, FuncCall):
args = [self.bind_expr(a) for a in expr.args]
bound = FuncCall(expr.name, [a for a, _ in args], expr.star)
if expr.name == "COUNT":
return bound, "int" # COUNT is always integral
if not args:
raise TypeError(f"{expr.name} requires an argument")
arg_type = args[0][1]
if expr.name == "AVG":
return bound, "numeric" # averaging integers yields a fraction
if expr.name in ("SUM", "MIN", "MAX"):
# exact for MIN/MAX; SUM is simplified — PostgreSQL widens
# sum(int4) to int8 and sum(int8) to numeric to avoid overflow
return bound, arg_type
raise NameError(f'function "{expr.name}" does not exist')
raise TypeError(f"cannot bind {expr!r}")Only now can these errors be produced:
column "ago" does not exist— no such column in the catalogcolumn reference "id" is ambiguous— both relations have anidoperator does not exist: text > integer— type mismatch
The line lt != rt and not {lt, rt} <= NUMERIC_TYPES compresses implicit casting into a single condition. Real PostgreSQL is far more elaborate: it collects every candidate operator from pg_operator and ranks them using the conversion rules in pg_cast. That machinery is what promotes the 100 in o.total > 100 to numeric.
Binding Normalizes Away Surface Differences
After binding, u.age and (when unambiguous) plain age both become the same Var(0, 2, "int"). Once the table is aliased as u, the name users is hidden, so users.age is an error in PostgreSQL and in the binder above. Every later optimization works on this normalized form, so it never has to care how the query was written.
Chapter 4: The Logical Plan — Dropping Into Relational Algebra
The bound AST becomes a tree of relational algebra operators. From here on SQL syntax is irrelevant; all that remains is which set operations to apply, in what order.
@dataclass
class Scan:
rel: int
relation: Relation
alias: str
predicate: object | None = None # evaluated while scanning
@dataclass
class Filter:
child: object
predicate: object
@dataclass
class Join:
left: object
right: object
predicate: object
@dataclass
class Project:
child: object
exprs: list
@dataclass
class Sort:
child: object
keys: list
@dataclass
class Aggregate:
child: object
group_keys: list
aggs: list # [FuncCall("COUNT", [], star=True), …]
@dataclass
class Limit:
child: object
n: intThe translation rules are direct.
| SQL clause | Relational-algebra operator |
|---|---|
FROM t | Scan(t) |
FROM a JOIN b ON p | Join(Scan(a), Scan(b), p) |
WHERE p | Filter(child, p) |
GROUP BY g | Aggregate(child, [g], aggs) |
HAVING h | Filter(Aggregate(…), h) |
SELECT e1, e2 | Project(child, [e1, e2]) |
ORDER BY k | Sort(child, [k]) |
LIMIT n | Limit(child, n) |
Several familiar SQL rules follow from this table. WHERE becomes a node below Aggregate, and HAVING one above it. That is why HAVING may reference aggregates and WHERE may not: WHERE sits at a position where aggregated values do not exist yet.
Applying these rules naively to the running example gives this tree:
Limit(10)
└─ Sort(o.total DESC)
└─ Project(u.name, o.total)
└─ Filter(u.age > 30 AND o.total > 100)
└─ Join(u.id = o.user_id)
├─ Scan(users AS u)
└─ Scan(orders AS o)The nesting order mirrors SQL's evaluation order (FROM → WHERE → SELECT → ORDER BY → LIMIT). Because SELECT sits above WHERE, an alias defined in the select list cannot be referenced in WHERE, a familiar SQL restriction that follows directly from this shape.
The tree returns the correct result, but slowly: it joins all 1,000,000 orders with all 100,000 users before filtering anything.
Chapter 5: Rewriting — Changing Shape Without Changing Meaning
Optimization starts with transformations that are clearly profitable without computing any cost. They come from the equivalence laws of relational algebra.
Predicate Pushdown
Push selection below the join. Fewer rows reach the join, which is almost always a win.
The key implementation trick is to split AND into conjuncts and push each one separately. As one lump, u.age > 30 AND o.total > 100 references both tables, so it cannot go below the join at all; split apart, each half can be pushed to its own table.
def conjuncts(expr) -> list:
"""Flatten a chain of ANDs into a list."""
if isinstance(expr, BinOp) and expr.op == "AND":
return conjuncts(expr.left) + conjuncts(expr.right)
return [expr]
def and_all(preds: list):
if not preds:
return None
node = preds[0]
for p in preds[1:]:
node = BinOp("AND", node, p)
return node
def used_rels(expr) -> set[int]:
"""The set of range-table indexes an expression references."""
if isinstance(expr, Var):
return {expr.rel}
if isinstance(expr, BinOp):
return used_rels(expr.left) | used_rels(expr.right)
return set()
def rels_of(node) -> set[int]:
if isinstance(node, Scan):
return {node.rel}
if isinstance(node, Join):
return rels_of(node.left) | rels_of(node.right)
return rels_of(node.child)
def push_down(node, preds: list):
"""Push predicates as deep as they can go.
Returns (rewritten subtree, predicates that could not be pushed)."""
if isinstance(node, Scan):
mine, stays = [], []
for p in preds:
(mine if used_rels(p) <= {node.rel} else stays).append(p)
node.predicate = and_all(mine)
return node, stays
if isinstance(node, Join):
node.left, preds = push_down(node.left, preds)
node.right, preds = push_down(node.right, preds)
here, stays = [], []
scope = rels_of(node)
for p in preds:
(here if used_rels(p) <= scope else stays).append(p)
return (Filter(node, and_all(here)) if here else node), stays
raise TypeError(node)Splitting WHERE with conjuncts() and feeding it to push_down() gives:
Limit(10)
└─ Sort(o.total DESC)
└─ Project(u.name, o.total)
└─ Join(u.id = o.user_id)
├─ Scan(users) filter: age > 30
└─ Scan(orders) filter: total > 100The join now sees 55,000 × 100,000 rows instead of 100,000 × 1,000,000 (chapter 6 shows where these estimates come from).
OR Cannot Be Pushed
WHERE u.age > 30 OR o.total > 100 cannot be split. OR is true when either side is true, so evaluating one side alone changes the answer. In practice this form can only be evaluated after the join, and it is a common cause of slow queries. The usual fixes are to rewrite it as a UNION (so each branch can be optimized independently) or to avoid the OR in the schema design.
Other Rewrites
- Column pruning — drop columns that nothing above the node references. The savings are largest in columnar stores, which is one reason to avoid
SELECT *. - Constant folding — compute an expression of constants once at plan time, the way
WHERE x = 2 + 2becomesWHERE x = 4. Only immutable functions qualify. An expression with a stable function, such asnow() - interval '7 days'(now()is constant within a statement), is not folded at plan time, because a reused plan (chapter 9) would keep a stale value; in an index scan condition it is evaluated once at execution start. Volatile functions such asrandom()cannot even be reduced to a single evaluation (function volatility categories). - Transitive predicate closure — derive
b.x = 10froma.x = b.x AND a.x = 10so the constant can also be pushed ontob. - Subquery decorrelation — turn a correlated subquery into a join. This is often one of the highest-impact rewrites.
Transitive closure is short enough to show. In practice, "why does only one of my two tables use its index?" is very often a question about whether this rewrite fired.
def transitive_closure(preds: list) -> list:
"""Build equivalence classes from x = y, then copy constant
predicates across every member of the class."""
classes: list[set] = [] # sets of Vars known to be equal
for p in preds:
if (isinstance(p, BinOp) and p.op == "="
and isinstance(p.left, Var) and isinstance(p.right, Var)):
merged = {p.left, p.right}
rest = []
for c in classes: # fuse overlapping classes
(merged.update(c) if merged & c else rest.append(c))
classes = rest + [merged]
derived = []
for p in preds:
if isinstance(p, BinOp) and isinstance(p.left, Var) and isinstance(p.right, Const):
for c in classes:
if p.left in c:
derived += [BinOp(p.op, v, p.right) for v in c if v != p.left]
return preds + derivedApplied to a.x = b.x AND a.x = 10, this produces b.x = 10, which push_down() can then sink all the way to the scan of b. PostgreSQL calls this machinery equivalence classes, implemented in equivclass.c.
An example of decorrelation:
-- Correlated: naively evaluated once per outer row
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100);
-- Rewritten as a semi join: one hash table, built once
SELECT u.name FROM users u
SEMI JOIN orders o ON o.user_id = u.id AND o.total > 100;SEMI JOIN is not valid SQL syntax, but it is a real operator inside the planner. It is what EXPLAIN shows as Hash Semi Join.
What Breaks Rewriting: Three-Valued Logic and Outer Joins
Everything above quietly assumed two things: that a predicate is either true or false, and that joins commute. SQL guarantees neither in general.
The first problem is NULL and three-valued logic. SQL has three truth values: TRUE, FALSE, and UNKNOWN. NULL > 30 is not FALSE; it is UNKNOWN.
AND | T | F | U OR | T | F | U
-------+---+---+--- -------+---+---+---
T | T | F | U T | T | T | T
F | F | F | F F | T | F | U
U | U | F | U U | T | U | UTwo consequences matter in practice.
WHEREkeeps a row only when the predicate is TRUE; UNKNOWN is discarded. So a predicate test should really readif pred(row) is True, and that is how it is spelled once chapter 15 rewires the scan to real storage.CHECKconstraints do the opposite and treat UNKNOWN as passing. The same expression flips its NULL behavior depending on where it is written.
This is also why NOT IN (… a list containing NULL) always returns nothing. x NOT IN (1, NULL) means x <> 1 AND x <> NULL, and the right conjunct is permanently UNKNOWN, so the whole thing can never be TRUE. Consequently the planner cannot safely convert NOT IN into an anti join, which is why it tends to be slower than NOT EXISTS.
The second problem is outer joins. Predicate pushdown is only unconditionally valid for inner joins.
-- Original: users with no orders survive the join
SELECT u.name, o.total
FROM users u LEFT JOIN orders o ON u.id = o.user_id
WHERE o.total > 100; -- ← discards the NULL-extended side anyway
-- This predicate cannot be moved out of the join into WHERE
SELECT u.name, o.total
FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.total > 100;In the first query o.total > 100 rejects NULLs, so the planner performs join strength reduction, turning the outer join into an inner join and regaining full freedom to reorder. In the second the predicate lives in the ON clause, so it does not reject NULL-extended rows. The join must stay an outer join, and the predicate cannot be moved out of the join into a filter on its result: that would drop users who have no matching order instead of NULL-extending them. Since it references only orders, it can still be applied to the orders scan before the join.
Outer joins also restrict join-order freedom: and generally differ. Chapter 8's dynamic program relies on the inner-join property that every split of a subset yields the same result, so it cannot be applied unchanged once outer joins appear. Representing joins as hyperedges instead of graph edges ("these relations must all be present before this join may run") is the idea behind DPhyp, which we meet in chapter 8.
Chapter 6: Statistics and Cardinality Estimation
The optimizer proper begins here, with the most fragile part of query optimization.
Comparing costs requires knowing how many rows each operator produces. That number is unknowable before execution, so it is estimated from statistics.
Histograms
ANALYZE samples a table and records the following in pg_statistic:
- Most common values (MCVs) and their frequencies — used for equality selectivity
- Equi-depth histogram bounds — used for range selectivity
- n_distinct — the number of distinct values (negative values express a fraction of the row count)
- Correlation — how closely the column order matches physical order, which drives random-I/O estimates for index scans
An equi-depth histogram places boundaries so that every bucket holds the same number of rows. Narrow buckets mean dense regions of the value distribution.
@dataclass
class ColumnStats:
n_distinct: int
bounds: list # equi-depth boundaries: len(bounds) - 1 buckets
def selectivity_gt(stats: ColumnStats, value) -> float:
"""Selectivity of col > value, assuming uniformity inside each bucket."""
bounds = stats.bounds
nb = len(bounds) - 1 # number of buckets
if value < bounds[0]:
return 1.0
if value >= bounds[-1]:
return 0.0
for i in range(nb):
lo, hi = bounds[i], bounds[i + 1]
if lo <= value < hi:
above_in_bucket = (hi - value) / (hi - lo)
return (nb - i - 1) / nb + above_in_bucket / nb
return 0.0Suppose the deciles of users.age are:
age_stats = ColumnStats(
n_distinct=63,
bounds=[18, 22, 25, 27, 29, 31, 34, 38, 44, 53, 80],
)
selectivity_gt(age_stats, 30) # → 0.55Following the arithmetic: 30 lands in bucket 4, [29, 31). Buckets 5 through 9 are entirely above it, contributing . Within bucket 4, lies above, contributing . Total: , or 55,000 rows.
Join Cardinality
The textbook estimate for an equi-join is the following, where is the number of distinct values (the n_distinct seen above).
def join_cardinality(left_rows, right_rows, ndv_left, ndv_right) -> float:
"""Equi-join estimate under the containment assumption: every value on the
side with fewer distinct values also occurs on the other side."""
return left_rows * right_rows / max(ndv_left, ndv_right)With 55,000 users rows (after filtering), 100,000 orders rows (assuming 10% of orders pass total > 100), and NDV(users.id) = NDV(orders.user_id) = 100,000:
When Estimation Breaks
Three strong assumptions underpin all of this:
- Uniformity — values are uniformly distributed inside each histogram bucket
- Independence — selectivities of separate predicates are independent and can be multiplied
- Containment — one join key's value domain is contained in the other's
None of them hold in practice, and independence fails most easily. Take WHERE city = 'Yokohama' AND prefecture = 'Kanagawa', where 1% of rows are in Yokohama and 5% are in Kanagawa. Assuming independence gives a selectivity of , but the true value is , because Yokohama determines the prefecture. That is a 20× underestimate.
The error also multiplies with every join level.
Leis et al., "How Good Are Query Optimizers, Really?" (PVLDB 9(3), 2015), compared the intermediate-result estimates of PostgreSQL 9.4, the research system HyPer and three commercial systems with the true cardinalities, using the 113 multi-join queries of their Join Order Benchmark over the real-world IMDB data set. All five systems routinely produced estimates off by a factor of 1,000 or more, and the errors grew exponentially with the number of joins. For PostgreSQL, 16% of the estimates for one join were off by a factor of 10 or more; the share rose to 32% with two joins and 52% with three. Their central conclusion is that cardinality estimation accuracy matters far more to final plan quality than cost model accuracy does.
Mitigations include:
- Multivariate statistics — collect statistics over correlated column groups. PostgreSQL offers
CREATE STATISTICS (dependencies, ndistinct, mcv). - Adaptive query execution — observe actual row counts at run time and switch plans. Oracle Adaptive Plans, SQL Server Adaptive Joins, Spark AQE.
- Learned estimators — train a model on execution history. Still mostly research, but reaching production.
Chapter 7: The Cost Model — What Actually Gets Counted
With row counts estimated, the planner can compute costs. A cost has no time unit; it is a dimensionless relative number that answers "if reading one page sequentially is 1.0, how much is everything else?"
import math
SEQ_PAGE_COST = 1.0 # read one page sequentially
RANDOM_PAGE_COST = 4.0 # read one page randomly
CPU_TUPLE_COST = 0.01 # process one tuple
CPU_INDEX_TUPLE_COST = 0.005 # process one index entry
CPU_OPERATOR_COST = 0.0025 # evaluate one operator or functionrandom_page_cost = 4.0 does not mean PostgreSQL believes a physical random read is only four times a sequential read. The documentation explicitly says the default is lower than raw hardware ratios because the majority of random reads, such as indexed reads, are assumed to be cached. On SSD-heavy or fully cached workloads, a common tuning step is to lower random_page_cost to match measurements, often to 1.1–2, and to set it equal to seq_page_cost only when random access is effectively no worse. That single constant moves the break-even point between index scans and sequential scans.
Scan Costs
def seq_scan_cost(rel: Relation, n_quals: int) -> float:
"""Read every block in order and evaluate n_quals conditions per tuple."""
return (rel.n_pages * SEQ_PAGE_COST
+ rel.n_rows * CPU_TUPLE_COST
+ rel.n_rows * n_quals * CPU_OPERATOR_COST)seq_scan_cost(CATALOG["users"], 1) # 1,000 + 1,000 + 250 = 2,250
seq_scan_cost(CATALOG["orders"], 1) # 10,000 + 10,000 + 2,500 = 22,500This formula does not depend on selectivity: returning one row still costs a full scan. An index scan, by contrast, costs roughly in proportion to selectivity. Where those two lines cross is where the plan flips.
| Access path | What it reads | I/O | When it wins | Gotcha |
|---|---|---|---|---|
| Seq Scan | Every block, in order | Sequential reads (prefetch friendly) | When most of the table is returned, or no index exists | Reads every block even to return a single row |
| Index Scan | Walk the index, then fetch the heap per TID | Random reads (up to one page per row) | When selectivity is low enough | As row counts grow, the same heap page may be read again and again |
| Index Only Scan | Reads only the index; never touches the heap | Index pages only | When every needed column is contained in the index | In PostgreSQL, limited to pages marked all-visible in the visibility map |
| Bitmap Heap Scan | Collect TIDs into a bitmap, then read the heap in physical order | Turns random reads into sequential ones | Moderate selectivity; AND/OR across several indexes | Degrades to page granularity once the bitmap exceeds work_mem |
The Cost of an Index Scan
Now the other line. Fetching rows through an index looks like random reads, but several rows share a page, so it costs less. The more rows you fetch, the higher the chance the next one lands on a page you have already read.
Mackert and Lohman quantified this in 1989, and PostgreSQL uses their formula verbatim.
def index_pages_fetched(tuples_fetched, pages, index_pages,
total_table_pages, effective_cache_size=524_288):
"""Mackert-Lohman: pages actually fetched given a finite buffer.
effective_cache_size defaults to 4 GB = 524,288 pages."""
T = float(pages) if pages > 1 else 1.0
# Every table in the query plus this index competes for cache;
# pro-rate by this table's share
total_pages = max(total_table_pages + index_pages, 1.0)
b = max(1.0, math.ceil(effective_cache_size * T / total_pages))
if T <= b: # the table fits in cache
pf = (2.0 * T * tuples_fetched) / (2.0 * T + tuples_fetched)
return T if pf >= T else math.ceil(pf)
lim = (2.0 * T * b) / (2.0 * T - b) # where re-reads start
if tuples_fetched <= lim:
return math.ceil((2.0 * T * tuples_fetched) / (2.0 * T + tuples_fetched))
return math.ceil(b + (tuples_fetched - lim) * (T - b) / T)Fetch 5,000 rows from users (1,000 pages / 100,000 rows):
The estimate is at most one table's worth of pages, not 5,000 random reads. The "one page per row" intuition breaks down as row counts grow.
Correlation — The Same Index, 75× Apart
The other factor is the correlation between index order and the physical order of the heap (the file that stores the table's rows). PostgreSQL computes two endpoints and interpolates between them.
def index_scan_cost(rel, index_pages, selectivity, correlation=0.0,
random_page_cost=RANDOM_PAGE_COST, total_table_pages=11_000):
tuples = rel.n_rows * selectivity
# Index side: read the matching entries
idx_pages = math.ceil(selectivity * index_pages)
idx_cost = (idx_pages * random_page_cost
+ tuples * (CPU_INDEX_TUPLE_COST + CPU_OPERATOR_COST))
# Heap side: interpolate the two endpoints by correlation squared
pf = index_pages_fetched(tuples, rel.n_pages, index_pages, total_table_pages)
max_io = pf * random_page_cost # correlation = 0
seq_pages = math.ceil(selectivity * rel.n_pages)
min_io = random_page_cost + max(seq_pages - 1, 0) * SEQ_PAGE_COST # = 1
heap_cost = max_io + correlation ** 2 * (min_io - max_io)
return idx_cost + heap_cost + tuples * CPU_TUPLE_COSTHolding selectivity at 5% (5,000 rows) and varying only correlation:
| Correlation | Heap I/O cost | Meaning |
|---|---|---|
| 0.0 | 4,000 | 1,000 pages, all read randomly |
| 0.5 | 3,013 | in between, nearer the 0.0 row (0.5² = 0.25) |
| 1.0 | 53 | 50 pages read sequentially (4.0 + 49 × 1.0) |
Same index, same row count, a 75× difference from correlation alone. The benefit of CLUSTER, or of time-series data whose insertion order happens to be ascending, reaches the cost model through this one interpolation.
There is no rigorous derivation for using the square of the correlation as the weight. costsize.c says so itself:
We currently interpolate linearly between the estimates based on the correlation squared (XXX is that appropriate?).
Production cost models contain acknowledged guesswork.
Finding the Crossover
Take the users(age) index at 275 pages, correlation 0, and compare against the Seq Scan cost of 2,250.
| Selectivity | Rows | Mackert–Lohman pages | Index Scan | Winner |
|---|---|---|---|---|
| 0.1% | 100 | 96 | 390 | Index Scan |
| 0.5% | 500 | 400 | 1,617 | Index Scan |
| 0.8% | 800 | 572 | 2,314 | roughly tied |
| 1.0% | 1,000 | 667 | 2,698 | Seq Scan |
| 5.0% | 5,000 | 1,000 | 4,144 | Seq Scan |
The crossover sits around 0.8% with the HDD-era random_page_cost = 4.0. Drop it to the SSD-oriented 1.1 and, for the same table and the same index, the crossover climbs into the tens of percent. Once random reads are cheap, the Mackert–Lohman clamp means the index scan's I/O stops growing, while the Seq Scan keeps paying CPU cost for every row in the table.
(This model omits index descent cost and B+ tree height, so real PostgreSQL crosses over at a lower selectivity. The exact number matters less than the fact that one constant moves the boundary by orders of magnitude.)
Join Costs
How to read a single table is settled. The next question is how to match two inputs against each other, and every join algorithm gets its own formula.
@dataclass
class Plan:
rows: float
cost: float
desc: str
def hash_join_cost(build: Plan, probe: Plan, out_rows: float) -> float:
return (build.cost + probe.cost
+ build.rows * CPU_OPERATOR_COST # building the hash table
+ probe.rows * CPU_OPERATOR_COST # probing it
+ out_rows * CPU_TUPLE_COST) # producing output tuples
def sort_cost(input_cost: float, rows: float, limit: int | None = None) -> float:
"""Following PostgreSQL, one comparison costs 2 * cpu_operator_cost.
A LIMIT turns this into a bounded heap, shrinking the logarithm."""
comparison_cost = 2.0 * CPU_OPERATOR_COST
keep = rows if limit is None else min(rows, limit)
return input_cost + comparison_cost * rows * math.log2(max(keep, 2.0))
def merge_join_cost(left: Plan, right: Plan, out_rows: float) -> float:
ls = sort_cost(left.cost, left.rows)
rs = sort_cost(right.cost, right.rows)
return (ls + rs
+ (left.rows + right.rows) * CPU_OPERATOR_COST
+ out_rows * CPU_TUPLE_COST)(The bounded branch of sort_cost is simplified: PostgreSQL's cost_tuplesort uses for a top-N heapsort. It is a constant-factor difference that does not change any ranking in this article, but keep it in mind when comparing these numbers against real EXPLAIN output.)
Three candidates for the join in the running example:
| Candidate | Breakdown | Cost |
|---|---|---|
NestedLoop (users × index scan) | 2,651,875 | |
| MergeJoin (sort both sides) | 38,323 | |
HashJoin (build = users) | 25,688 |
The 48.175 in the nested-loop row is the cost of one inner index lookup. Each user has about 10 orders, so we read 2 index pages and 10 heap pages randomly, plus CPU cost . That is , repeated 55,000 times, which puts it far above the other two candidates.
But make the outer side a single row (WHERE u.id = 42) and the same nested loop becomes the fastest option. No join algorithm is universally better; the ranking flips with row counts. That is why cost-based optimization is needed.
Choosing the Build Side
hash_join_cost looks symmetric in build and probe, but the build side should be the smaller one. The hash table has to fit in memory (work_mem in PostgreSQL). If it does not, execution degrades to a grace hash join, which partitions both sides by hash value, spills the partitions to temporary files, and processes them one at a time, adding I/O. The planner folds this in by increasing the batch count when the estimated build size exceeds work_mem.
Startup Cost and Total Cost
Cost has been one scalar so far, but EXPLAIN prints this:
Hash Join (cost=2387.50..25687.50 rows=55000 width=36)There are two numbers. The first is the startup cost: what must be paid before the first row can be returned. The second is the total cost: everything, through the last row.
They are tracked separately because a parent node does not always need every row. The cost of a partial retrieval follows from a formula written at the top of costsize.c:
@dataclass
class CostedPlan:
rows: float
startup_cost: float
total_cost: float
def cost_for(plan: CostedPlan, tuples_to_fetch: float) -> float:
"""Cost of a partial retrieval. Startup is paid regardless, so it is not pro-rated."""
frac = min(tuples_to_fetch, plan.rows) / plan.rows
return plan.startup_cost + (plan.total_cost - plan.startup_cost) * frac"Blocking" Means "High Startup Cost"
A hash join's startup cost includes the entire total cost of the build side, because no row can be emitted until the hash table is finished. An operator with that property (nothing comes out until the input has been drained) is called blocking; chapter 10 implements a few.
def hash_join_startup(build: Plan) -> float:
"""Startup runs until the build side is drained and hashed."""
return build.cost + build.rows * CPU_OPERATOR_COSTFor the hash join in the running example that is , against a total of . This pair of numbers is how the planner represents "blocking."
(PostgreSQL's initial_cost_hashjoin also charges cpu_tuple_cost per build row, giving . We stay consistent with this chapter's simplified model here.)
Add a LIMIT and the Ranking Inverts
Chapter 9 will claim that LIMIT changes the algorithm chosen below it. These two numbers are enough to check that claim.
| Query | Plan | Arithmetic | Cost |
|---|---|---|---|
… ORDER BY o.total DESC LIMIT 10 | Sort blocks | startup ≈ total | 26,601 |
… LIMIT 10 (no ORDER BY) | HashJoin | 2,392 | |
… LIMIT 10 (no ORDER BY) | NestedLoop + IndexScan | 482 |
For all rows the hash join (25,688) beats the nested loop (2,651,875) by more than 100×. Ask for ten rows and the ranking flips, because the nested loop has essentially zero startup cost and only has to read as many outer rows as it needs.
The same arithmetic explains the classic incident "adding LIMIT 10 made the query 1,000× slower." The planner assumes ten outer rows will yield ten results and picks the zero-startup nested loop; if matching rows are actually rare, execution scans the outer side to the end. Cardinality misestimates are never more punishing than under a LIMIT.
Cursors (DECLARE … CURSOR) always use this partial-retrieval costing. Following cursor_tuple_fraction (default 0.1), they are planned as if only 10% of rows will be fetched, which is why the same SQL can get a different plan through a cursor.
Chapter 8: Join Order — Fighting the Search Space
With three or more relations, the order of joins matters more than the algorithm chosen for each one.
How Big Is the Space?
For relations there are left-deep trees, and bushy trees.
| Relations n | Left-deep trees n! | Bushy trees (2n−2)!/(n−1)! | DP table entries 2ⁿ |
|---|---|---|---|
| 2 | 2 | 2 | 4 |
| 4 | 24 | 120 | 16 |
| 6 | 720 | 30,240 | 64 |
| 8 | 40,320 | 17,297,280 | 256 |
| 10 | 3,628,800 | 17,643,225,600 | 1,024 |
The number of trees to enumerate grows factorially, but dynamic programming only tracks 2ⁿ subsets. That still grows exponentially, so PostgreSQL switches to a genetic algorithm once n reaches geqo_threshold (12 by default).
Exhaustive enumeration is impossible, but optimal substructure saves us. If a plan uses the join of as an intermediate result, the subtree that produces it can be replaced by the best plan for alone without making the whole plan worse (given a monotone cost function). So one best plan per subset suffices, and the state count collapses to . That is the dynamic program System R introduced in 1979.
def dp_join_order(rels, cardinality, connected):
"""Fix the optimal plan for every subset, smallest first (DPsize)."""
n = len(rels)
best = {}
for i in range(n):
best[1 << i] = Plan(rows=rels[i].n_rows, cost=0.0, desc=rels[i].name)
for size in range(2, n + 1):
for mask in range(1, 1 << n):
if bin(mask).count("1") != size:
continue
if not connected(mask): # prune Cartesian products
continue
rows = cardinality(mask)
for right in submasks(mask): # enumerate proper subsets
left = mask ^ right
if left == 0 or right == 0:
continue
if left not in best or right not in best:
continue
if not has_join_edge(left, right):
continue
cost = rows + best[left].cost + best[right].cost # C_out
if mask not in best or cost < best[mask].cost:
best[mask] = Plan(rows, cost,
f"({best[left].desc} ⋈ {best[right].desc})")
return best[(1 << n) - 1]
def submasks(mask: int):
"""The classic idiom for enumerating all non-empty proper submasks."""
sub = (mask - 1) & mask
while sub > 0:
yield sub
sub = (sub - 1) & maskThe submasks idiom (sub - 1) & mask walks the submasks of mask in descending order, and enumerating all submasks of all subsets is .
The cost function here is C_out, the sum of intermediate result sizes. It ignores scan and algorithm costs, yet it captures most of what makes one join order better than another.
The demo below runs it on a four-table join of users (u), orders (o), order_items (i), and products (p), with products filtered down to 10 rows.
Choosing a Join Order With Dynamic Programming
For a four-table join, the optimal sub-plan for every subset is determined in increasing order of size. Subsets that would require a Cartesian product are pruned from the search space.
Initialization: single-table access paths are entered into the DP table. Scan cost is treated as 0 here — we use the C_out cost model, which accumulates only the sizes of intermediate results.
| Subset | Est. rows | Cost | Best sub-plan |
|---|---|---|---|
| {u} | 10,000 | 0 | u |
| {o} | 100,000 | 0 | o |
| {i} | 500,000 | 0 | i |
| {p} | 10 | 0 | p |
The order as written, , costs 605,000; the optimal costs 15,000, roughly 40× cheaper. The intuition "start from the most selective relation" turns out to have a precise cost-based justification.
Pruning Cartesian Products
The connected(mask) check inside the loop does much of the work. A subset that no join predicate connects can only be produced by a Cartesian product, which explodes row counts, so skipping such subsets shrinks the search space enormously. For a chain-shaped join graph, the candidate count drops to .
Pruning too aggressively has a cost, though. When one relation is tiny (a dimension table, say), the Cartesian product is sometimes genuinely faster. Many implementations allow cross products only as a fallback, when no connected subset can be formed.
When n Gets Large
PostgreSQL abandons dynamic programming once the number of relations being joined reaches geqo_threshold (default 12) and switches to a genetic algorithm (GEQO). Optimality is no longer guaranteed, but planning time stays bounded.
In practice, though, join_collapse_limit (default 8) bites first. Beyond eight relations written with explicit JOIN syntax, the planner stops flattening the FROM clause and keeps the written order. If you reproduce this chapter's DP on a real ten-table explicit-join query and nothing gets reordered, suspect this setting first. (Subquery flattening has its own knob, from_collapse_limit.)
Conversely, setting join_collapse_limit = 1 prevents any reordering of explicit JOINs, which is the standard way to pin a join order by hand, the last resort when the estimates cannot be fixed.
Other systems and papers take different approaches:
- DPccp / DPhyp (Moerkotte & Neumann) — enumerate only connected subgraphs of the join graph, never constructing useless subsets. DPccp, for simple join graphs, is the VLDB 2006 paper; DPhyp, which generalizes it to hypergraphs (outer joins and complex predicates), is "Dynamic Programming Strikes Back", SIGMOD 2008. Used by DuckDB and HyPer.
- Cascades / Volcano framework (Graefe) — top-down search driven by transformation rules and a memo structure. SQL Server, Apache Calcite, and CockroachDB follow this lineage. Unlike bottom-up DP, it can extend the search space just by adding rules.
- Interesting orders — treat sort order as part of the state. A sorted intermediate result can be reused by a later merge join or
ORDER BY, so a locally more expensive plan can win globally. The idea already appears in the System R paper.
Chapter 9: The Physical Plan — Committing to Algorithms
Each logical operator maps to several physical ones.
| Logical operator | Physical candidates |
|---|---|
Scan | Seq Scan / Index Scan / Index Only Scan / Bitmap Heap Scan |
Join | Nested Loop / Hash Join / Merge Join |
Sort | Quicksort (in memory) / External Merge Sort / Top-N Heapsort |
Aggregate | Hash Aggregate / Sorted Aggregate |
Distinct | Hash / Sort + Unique |
The final physical plan:
Limit(10) rows=10 cost≈26,601
└─ Sort(o.total DESC) top-N heapsort rows=10 cost≈26,601
└─ HashJoin(u.id = o.user_id) rows=55,000 cost≈25,688
├─ SeqScan(orders) rows=100,000 cost≈22,500 [probe]
│ Filter: total > 100
└─ Hash rows=55,000
└─ SeqScan(users) rows=55,000 cost≈2,250 [build]
Filter: age > 30Sort became a top-N heapsort because of LIMIT 10. Sorting all 55,000 rows costs ; keeping a 10-element heap costs . Memory drops from to as well.
In physical optimization, a parent node's requirement often changes the algorithm chosen below it. LIMIT pushes down "how many rows are needed"; ORDER BY pushes down "in what order."
Properties that constrain how a result is produced without changing what is computed are called physical properties. The canonical examples are sort order (PostgreSQL's PathKey) and data distribution (partitioning, in a distributed engine). Cascades-style optimizers make them part of the search state, remembering "the cheapest plan for this subset in this sort order" separately for each order. Chapter 8's interesting orders are the ancestor of that idea.
Reusing Plans — Prepared Statements and Generic Plans
Everything from chapter 1 to here is expensive. An application firing the same query thousands of times per second should not re-lex it every time, so implementations cache plans.
That introduces a trade-off. Caching a parameterized query means building a plan without knowing the parameter values, and chapter 6's selectivity_gt cannot compute anything without a value.
DEFAULT_INEQ_SEL = 0.3333333333333333 # selfuncs.h
DEFAULT_EQ_SEL = 0.005
UNKNOWN = object() # an unbound parameter
def selectivity_gt(stats: ColumnStats, value) -> float:
if value is UNKNOWN:
return DEFAULT_INEQ_SEL # no histogram lookup possible
... # otherwise, as in chapter 6Evaluating WHERE u.age > $1 against the very same histogram:
| Plan | Selectivity | Est. rows | Plan chosen |
|---|---|---|---|
custom, $1 = 30 | 0.55 | 55,000 | HashJoin, cost 25,688 |
custom, $1 = 79 | 0.0037 | 370 | NestedLoop + IndexScan |
| generic (value unknown) | 0.3333 | 33,333 | a middle plan that suits neither |
The generic plan is built for 33,333 rows: too small for $1 = 30, too large for $1 = 79.
PostgreSQL compromises by trying custom plans first. choose_custom_plan() in plancache.c:
- always builds a custom plan for the first five executions
- compares their average estimated cost against the one generic plan
- keeps the generic plan thereafter if it is cheaper (and skips planning entirely)
This explains the pattern "it was fast for the first few calls and then suddenly 100× slower." To force one kind of plan, set plan_cache_mode to force_custom_plan or force_generic_plan.
Avoiding parameters is not a free escape. Interpolating values into the SQL string every time lets the planner estimate with the real values, but it defeats plan caching entirely and opens the door to SQL injection. Estimation accuracy and plan reuse are in tension by construction.
Chapter 10: The Execution Engine — The Volcano Iterator Model
The plan exists. Time to run it.
The Volcano model that Goetz Graefe introduced in 1990 (also called the iterator model, or pull-based execution) gives every operator the same three methods.
class Operator:
def open(self) -> None: ... # initialize state
def next(self) -> dict | None: ... # return one tuple, or None when done
def close(self) -> None: ... # release resourcesThe uniform interface is the model's main strength: any operator stacks on top of any other, so the planner is free to choose the shape of the tree.
Implementation
class SeqScanExec:
def __init__(self, rows, predicate=None):
self.rows, self.predicate = rows, predicate
self.cursor = 0
def open(self):
self.cursor = 0
def next(self):
while self.cursor < len(self.rows):
row = self.rows[self.cursor]
self.cursor += 1
if self.predicate is None or self.predicate(row):
return row
return None
def close(self):
pass
class NestedLoopJoinExec:
def __init__(self, outer, inner, predicate):
self.outer, self.inner, self.predicate = outer, inner, predicate
self.outer_tuple = None
def open(self):
self.outer.open()
self.inner.open()
self.outer_tuple = None
def next(self):
while True:
if self.outer_tuple is None:
self.outer_tuple = self.outer.next()
if self.outer_tuple is None:
return None
self.inner.open() # rewind the inner side (rescan)
inner_tuple = self.inner.next()
if inner_tuple is None:
self.outer_tuple = None # inner exhausted; advance the outer
continue
if self.predicate(self.outer_tuple, inner_tuple):
return {**self.outer_tuple, **inner_tuple}
def close(self):
self.outer.close()
self.inner.close()The demo below follows the call chain one event at a time: next() descending from root to leaves, and tuples climbing back from leaves to root.
The Volcano Iterator Model — next() Goes Down, Tuples Come Up
A Projection → NestedLoopJoin → Filter → SeqScan tree executed in pull style. The inner SeqScan(orders) is rescanned for each outer row, so its next() is called most often.
Blocking Operators
Not every operator can return one tuple at a time. Sort cannot return its first row until it has seen the last one. HashJoin cannot probe until the build side is fully consumed. These are the blocking operators (also called pipeline breakers) that chapter 7 met from the cost side, where their startup cost included the whole cost of the input they drain first. Here is what they look like as code.
from collections import defaultdict
class HashJoinExec:
def __init__(self, build, probe, build_key, probe_key):
self.build, self.probe = build, probe
self.build_key, self.probe_key = build_key, probe_key
def open(self):
# ── build phase: drain the build side completely (blocking)
self.build.open()
self.table = defaultdict(list)
while (row := self.build.next()) is not None:
self.table[self.build_key(row)].append(row)
self.build.close()
# ── prepare the probe phase
self.probe.open()
self.matches = iter(())
self.current = None
def next(self):
while True:
m = next(self.matches, None)
if m is not None:
return {**m, **self.current}
self.current = self.probe.next()
if self.current is None:
return None
self.matches = iter(self.table.get(self.probe_key(self.current), ()))
def close(self):
self.probe.close()
self.table = NoneWhere blocking operators sit determines how much a query has to read before it returns its first row. LIMIT 10 still reads every row when an ORDER BY is present, because Sort blocks. Without ORDER BY, LIMIT 10 stops calling next() after ten rows.
The Top-N Heap
ORDER BY … LIMIT n can be done with a heap of size instead of a full sort.
import heapq
import itertools
class TopNExec:
def __init__(self, child, key, n, desc=True):
self.child, self.key, self.n, self.desc = child, key, n, desc
def open(self):
self.child.open()
heap = [] # min-heap of (sort key, seq, row)
seq = itertools.count()
while (row := self.child.next()) is not None:
k = self.key(row)
if not self.desc:
k = -k # flip the sign for ASC
if len(heap) < self.n:
heapq.heappush(heap, (k, next(seq), row))
elif k > heap[0][0]: # only replace the current worst
heapq.heapreplace(heap, (k, next(seq), row))
heap.sort(key=lambda x: x[0], reverse=True)
self.out = iter([row for _, _, row in heap])
def next(self):
return next(self.out, None)
def close(self):
self.out = NoneThe next(seq) counter in the second tuple position prevents Python from falling through to comparing the row dictionaries on key ties, which would raise TypeError. It is the standard defensive idiom when using heapq with non-comparable payloads.
Expression Evaluation Does Not Walk a Tree
Every operator so far has treated predicate(row) as a plain Python function. Real implementations put one more layer here.
The obvious approach is to walk the bound expression tree recursively, once per row. Because the shape of that tree never changes at run time, PostgreSQL instead compiles it once at executor-init time, into a flat array of steps, and afterwards just runs a for loop over that array. That is what ExprState and the EEOP_* steps are.
Filter: (total > 100 AND user_id = 42) becomes roughly this:
0 EEOP_SCAN_FETCHSOME last_var=3 # deform only up to the highest attnum used
1 EEOP_SCAN_VAR attnum=3 # total
2 EEOP_CONST value=100
3 EEOP_FUNCEXPR_STRICT numeric_gt # skip the call entirely if an arg is NULL
4 EEOP_QUAL jumpdone=9 # FALSE and NULL both fail immediately
5 EEOP_SCAN_VAR attnum=2 # user_id
6 EEOP_CONST value=42
7 EEOP_FUNCEXPR_STRICT int4eq
8 EEOP_QUAL jumpdone=9
9 EEOP_DONE(Opcode names move between releases. The listing uses the names through PostgreSQL 17; in 18 EEOP_DONE was split into EEOP_DONE_RETURN / EEOP_DONE_NO_RETURN, and two-argument strict functions became EEOP_FUNCEXPR_STRICT_2. Also, attnum is stored zero-based in the step struct (varattno - 1); only last_var is a real 1-based attribute number. The shape is what matters.)
Three things this article has discussed elsewhere are embedded in that array, in machine form.
The last_var on EEOP_SCAN_FETCHSOME limits chapter 15's deform_tuple, which this step invokes, to the highest attribute number the expression references. Columns can only be extracted front to back, so if nothing beyond attnum 3 is used, attribute 4 onwards is never touched. That is one reason SELECT * on a 40-column table is slower than naming three columns: the executor pays for the longer loop before a single byte reaches the network (per-column output-function calls and transfer volume then pile on top of it).
The _STRICT suffix is chapter 5's three-valued logic, made mechanical. A strict function returns NULL whenever an argument is NULL, so the step short-circuits to NULL without calling the function at all.
EEOP_QUAL turns the rule "WHERE only passes TRUE" into an instruction. Whether the previous result was FALSE or NULL, it jumps to the end and fails the row. An AND written inside an expression uses EEOP_BOOL_AND_STEP_* instead. That one must distinguish FALSE from UNKNOWN, so it threads an anynull flag through the steps: FIRST clears the flag, the middle steps set it on NULL and bail out early on FALSE, and LAST turns "no FALSE but at least one NULL" into NULL. Splitting those roles across three opcodes (FIRST / middle / LAST) instead of branching at run time is a deliberate performance special case.
This flat array is also what PostgreSQL's JIT compiles: an interpreter loop with an indirect branch per step becomes straight-line native code. When chapter 12 says PostgreSQL's JIT targets expression evaluation and tuple deforming, this array is what it means.
Chapter 11: When Memory Runs Out — External Sort and Hash Aggregation
Every operator in the previous chapter assumed its intermediate results fit in memory. When they do not, the two canonical cases, sorting and aggregation, fail in completely different ways.
External Merge Sort
Chapter 7 introduced work_mem as the line a hash table has to stay under. For sorting, crossing that line looks like this:
def sort_external(rows, key, work_mem_rows, tmp):
"""Run generation, then merge. The skeleton of tuplesort.c."""
runs, buf = [], []
for row in rows:
buf.append(row)
if len(buf) == work_mem_rows: # work_mem is exhausted
buf.sort(key=key)
runs.append(tmp.spill(buf)) # write the sorted chunk to a temp file
buf = []
if buf:
buf.sort(key=key)
runs.append(tmp.spill(buf))
# Keep one row per run in a heap; pop the smallest and refill from that run
return heapq.merge(*(tmp.read(r) for r in runs), key=key)One merge pass would suffice if every run could be merged at once, but each run being merged (a "tape" in PostgreSQL's code) needs its own read buffer. PostgreSQL budgets 32 blocks (MERGE_BUFFER_SIZE = 256 KB) plus per-tape overhead for each one, so at work_mem = 4MB roughly 15 runs can be merged simultaneously (tuplesort_merge_order()). More runs than that require additional merge passes.
PostgreSQL's cost_tuplesort writes exactly that:
The comment on that last weighting admits it is a guess:
The disk traffic is assumed to be 3/4ths sequential and 1/4th random accesses (XXX can't we refine that guess?)
Two sizes, assuming 60 bytes per row:
- 55,000 rows ≈ 3.3 MB — fits the default 4 MB
work_mem, so it is an in-memory quicksort and touches no disk at all. - 5,500,000 rows ≈ 330 MB — runs and , so passes.
- disk
- CPU
So in this cost model, spilling to disk adds only about 46% on top of the comparison cost. Sorting degrades gracefully past work_mem. Hashing is what falls off a cliff: a hash join has to repartition and rewrite both inputs, and a hash aggregate, which only ever has one, repartitions that one recursively.
Also, temp files bypass the buffer pool entirely. Chapter 14's shared_buffers is for tables and indexes; sort spills are read and written directly under base/pgsql_tmp/. That is how "there is plenty of RAM but the sort is slow" happens.
Actual behavior shows up in EXPLAIN ANALYZE, where chapter 9's three sort variants appear verbatim as strings:
Sort Method: top-N heapsort Memory: 25kB
Sort Method: quicksort Memory: 3400kB
Sort Method: external merge Disk: 341000kBAggregation — Hash or Sort
Chapter 9's operator table listed Aggregate → Hash Aggregate / Sorted Aggregate. Here are both: hash aggregation blocks, and sorted aggregation does not.
class HashAggExec:
"""Blocking: keeps per-group state in a hash table."""
def __init__(self, child, group_keys, init, accum, final):
self.child, self.group_keys = child, group_keys
self.init, self.accum, self.final = init, accum, final
def open(self):
self.child.open()
table = {}
while (row := self.child.next()) is not None: # ← drains everything
k = tuple(row[c] for c in self.group_keys)
# Never write `table.get(k) or self.init()`: the moment an
# accumulator's state goes falsy (0, False) it would reset.
state = table[k] if k in table else self.init()
table[k] = self.accum(state, row)
self.out = iter([(k, self.final(s)) for k, s in table.items()])
def next(self):
return next(self.out, None)
def close(self):
self.child.close()
class SortedAggExec:
"""Non-blocking: emits a row the instant the key changes. Input must be sorted."""
def __init__(self, child, group_keys, init, accum, final):
self.child, self.group_keys = child, group_keys
self.init, self.accum, self.final = init, accum, final
def open(self):
self.child.open()
self.cur_key, self.state = None, None
def next(self):
while True:
row = self.child.next()
if row is None:
if self.cur_key is None:
return None
out = (self.cur_key, self.final(self.state)) # the last group
self.cur_key = None
return out
k = tuple(row[c] for c in self.group_keys)
if self.cur_key is None:
self.cur_key = k
self.state = self.accum(self.init(), row)
elif k == self.cur_key:
self.state = self.accum(self.state, row)
else:
out = (self.cur_key, self.final(self.state)) # key changed
self.cur_key = k
self.state = self.accum(self.init(), row)
return out
def close(self):
self.child.close()In PostgreSQL's cost model, their total costs are exactly the same. From cost_agg in costsize.c:
Note: in this cost model, AGG_SORTED and AGG_HASHED have exactly the same total CPU cost, but AGG_SORTED has lower startup cost.
Work it out for SELECT user_id, count(*) FROM orders GROUP BY user_id. The input is SeqScan(orders) (1,000,000 rows at cost 20,000), and the group count is NDV(user_id) = 100,000.
| Startup cost | Total cost | |
|---|---|---|
| HashAgg | ||
| SortedAgg (input already sorted) |
The totals are identical at 23,500; only the startup cost (22,500 versus 0) separates them, which is exactly the distinction chapter 7 introduced.
But "input already sorted" only counts when the order is free. Sorting on purpose costs
which loses to HashAgg's 23,500 by more than 5×. So the aggregation strategy is decided by whether an existing index or a lower merge join happens to supply the order. This is where chapter 8's interesting orders pay off in practice.
HashAgg has its own weakness: the hash table can overflow. The budget is work_mem × hash_mem_multiplier (default 2.0 since PostgreSQL 15), not plain work_mem, and a hash join's hash table gets the same budget. On spill, cost_agg charges random_page_cost per written page and seq_page_cost per page read back, then doubles both. The comment reads: "HashAgg has somewhat worse IO behavior than Sort on typical hardware/OS combinations." Once again, sorting is the one whose cost grows more gently when it spills.
The demo below puts the two failure modes side by side. Try moving the work_mem slider. The demo feeds the same input as this chapter's sort example (5,500,000 rows, 330 MB, grouped on 1,000,000 distinct keys) to both operators, not the 1,000,000-row / 100,000-group example in the table above:
Crossing work_mem — How Sorting and Hash Aggregation Break
Sort and hash-aggregation costs for the same 330 MB input, computed with PostgreSQL-style cost formulas as work_mem changes: sorting degrades smoothly, hash aggregation in steps.
- runs
- 79
- merge order
- 15
- passes
- 2
- CPU
- 615,753
- disk
- 281,988
- total
- 897,741
- budget
- 8 MB
- hash table
- 107 MB
- batches
- 14
- recursion depth
- 1
- CPU
- 23,750
- spill
- 512,840
- total
- 536,590
5,500,000 rows × 60 bytes = 330,000,000 bytes, processed with work_mem = 4 MB (4 × 1,048,576 bytes). The sort orders every row; the aggregate folds that same input into 1,000,000 groups (this is the chapter's sort example). Sorting gets work_mem itself; hash aggregation gets hash_mem_multiplier = 2× that, i.e. 8 MB. The sort does not fit. The hash table, 1,000,000 × 112 B ≈ 107 MB, does not fit.
The hash-aggregate line is flat from 4 to 32 MB because cost_agg keys off an integer — the recursion depth — rather than a continuous quantity. Raising work_mem across this range cuts the batch count, but the depth stays at 1, so the cost does not move. The plateau is a property of the model, not a claim that real runtimes are constant there.
The chart as a table
| work_mem | sort | hash agg |
|---|---|---|
| 4 MB | 1.46× | 22.59× |
| 8 MB | 1.46× | 22.59× |
| 16 MB | 1.23× | 22.59× |
| 32 MB | 1.23× | 22.59× |
| 64 MB | 1.23× | 1.00× |
| 128 MB | 1.23× | 1.00× |
| 256 MB | 1.23× | 1.00× |
| 512 MB | 1.00× | 1.00× |
Measured behavior appears on the HashAggregate node in EXPLAIN ANALYZE (the numbers below are an illustrative example). Batches of 2 or more means the hash table spilled, and only then is Disk Usage printed:
HashAggregate (actual rows=100000 loops=1)
Batches: 5 Memory Usage: 6273kB Disk Usage: 21456kBChapter 12: Beyond Volcano — Vectorization, Code Generation, and Parallelism
The Volcano model we have been building has a performance problem: returning one row costs one function call per operator. Ten million rows times five operators is fifty million calls. At tens of nanoseconds each, that alone is seconds. Worse, virtual calls defeat branch prediction, so the overhead can exceed the actual work (one comparison).
There are three ways out of that constraint: change the granularity (vectorization), erase the open/next/close contract altogether (code generation), and clone the tree and run copies side by side (parallelism).
Vectorization and Code Generation
| Execution model | Unit returned by next() | Call count | Strengths | Weaknesses | Used by |
|---|---|---|---|---|---|
| Volcano (tuple at a time) | 1 tuple | rows × operators | Simple to implement; operators compose freely | Virtual calls and branch mispredictions can cost more than the work itself | PostgreSQL, MySQL, Oracle |
| Vectorized (batch) | batch of ~1,024 tuples | rows ÷ batch size × operators | Three orders of magnitude fewer calls; SIMD and columnar layouts pay off | Needs memory for intermediate batches; a poor fit for row stores | DuckDB, ClickHouse, Velox, Photon |
| Code generation (JIT) | whole pipeline | 0 (fused away) | No intermediate tuples are materialized; values stay in registers | Compilation time; not worth it for short queries | HyPer, Umbra, Spark (whole-stage codegen) |
Vectorized execution makes next() return a batch of roughly 1,024 rows instead of one tuple. Call counts drop by three orders of magnitude, and the inside of a batch becomes a tight loop where SIMD and loop unrolling pay off. MonetDB/X100 (CIDR 2005) started this line; DuckDB and ClickHouse follow it.
Code generation goes further and eliminates the operator tree, fusing operators into tight loops. Neumann's "Efficiently Compiling Efficient Query Plans for Modern Hardware" (VLDB 2011) formalized the push-based pipeline: split the plan at pipeline breakers and compile each pipeline into one loop. Intermediate tuples are never materialized; values stay in registers.
PostgreSQL's LLVM-based JIT is easy to confuse with whole-plan code generation, but it does not eliminate the operator tree. It compiles only expression evaluation and tuple deforming (the EEOP_* array from chapter 10) into native code.
Parallel Execution — Where to Put the Gather
The third way is to run parts of the operator tree in several processes at once. Every plan so far has been serial. A parallel plan collects the workers' rows in a Gather node, and the cost arithmetic shows that whether parallelism makes the plan cheaper depends on where that node goes.
First, the worker count. PostgreSQL derives it logarithmically from table size: the first worker comes once the table reaches min_parallel_table_scan_size (default 8 MB = 1,024 pages), and another is added on every tripling.
| Pages | Size | Workers |
|---|---|---|
| < 1,024 | < 8 MB | 0 (serial) |
| ≥ 1,024 | 8 MB | 1 |
| ≥ 3,072 | 24 MB | 2 |
| ≥ 9,216 | 72 MB | 3 |
| ≥ 27,648 | 216 MB | 4 |
users is 1,000 pages: 24 pages short, so it never goes parallel. orders at 10,000 pages would earn 3 workers, but max_parallel_workers_per_gather (default 2) caps it at 2.
Next, the divisor. The leader participates too, so the divisor is not simply the worker count. But the more workers there are, the busier the leader gets collecting their output, so its contribution is discounted by 0.3 per worker. Once that discount leaves nothing (4 workers or more), the leader is not counted at all.
And only CPU cost is divided. The same total number of pages still has to be read, so the I/O term survives intact.
That would be a clear win, but shipping rows from workers to the leader is not free. parallel_tuple_cost defaults to 0.1, ten times cpu_tuple_cost, and starting the workers costs parallel_setup_cost = 1,000.
Put the Gather directly above the scan and you lose:
Put it above the join and you win, because only 55,000 rows cross the queue instead of 100,000.
| Node | Cost |
|---|---|
| Seq Scan users (inner side; not divided) | 2,250 |
| Parallel Seq Scan orders | 15,208.3 |
| build + probe + output CPU | ≈ 471 |
| Hash Join subtotal | ≈ 17,929 |
| + setup 1,000 + 0.1 × 55,000 | 24,429 |
| (for comparison) serial Hash Join | 25,688 |
Gather (cost=3387.50..24429.00 rows=55000 width=36)
Workers Planned: 2
-> Hash Join (cost=2387.50..17929.00 rows=22917 width=36)
Hash Cond: (o.user_id = u.id)
-> Parallel Seq Scan on orders o (cost=0.00..15208.33 rows=41667 width=20)
Filter: (total > '100'::numeric)
-> Hash (cost=2250.00..2250.00 rows=55000 width=24)
-> Seq Scan on users u (cost=0.00..2250.00 rows=55000 width=24)
Filter: (age > 30)The small rows= values under Gather are not a mistake: they are per-worker counts (100,000 / 2.4 = 41,667 and 55,000 / 2.4 = 22,917). This often trips up readers of their first parallel plan.
Two rules follow.
1. The Gather wants to be as high in the tree as possible, because every row crossing the boundary costs 40 cpu_operator_cost units. That is why PostgreSQL builds partial paths bottom-up and only caps them with a Gather at the top, and why Gather Merge exists: preserving order on the way up means the Sort need not sit above the Gather.
2. The inner side of a join is not divided: in the plan above, every worker builds its own copy of the users hash table (PostgreSQL 11+ can share one via Parallel Hash). So the hash budget is charged per node per process, and the leader executes the plan too: 2 workers + the leader = 3 processes × 1 hash node × 8 MB (the previous chapter's work_mem 4 MB × hash_mem_multiplier 2.0) = 24 MB. A plan with several hash nodes multiplies further. That multiplication is what makes the folk remedy for a spill ("just raise work_mem") dangerous.
Not everything can live below a Gather, either. Operations that write or lock rows, executions that might be suspended partway (DECLARE … CURSOR, PL/pgSQL's FOR … IN query LOOP), and PARALLEL UNSAFE functions all force serial execution. (SERIALIZABLE isolation did too, through PostgreSQL 11; that restriction was lifted in 12.) A non-zero max_parallel_workers_per_gather is necessary but not sufficient.
The running example of this article would in fact go parallel on a default PostgreSQL (24,429 < 25,688). Presenting the serial plan everywhere else is a simplification that keeps the discussion to a single execution path, the equivalent of max_parallel_workers_per_gather = 0.
Chapter 13: Access Methods — From Logical Rows to Physical Locations
The SeqScanExec built back in chapter 10 read from a Python list. Real storage starts here.
TIDs — The Physical Address of a Row
PostgreSQL identifies a row's physical location with a TID (tuple identifier, exposed as ctid): a pair of (block number, line-pointer number).
SELECT ctid, id, name FROM users LIMIT 3;
-- ctid | id | name
-- -------+-----+------
-- (0,1) | 1 | Ann
-- (0,2) | 2 | Bob
-- (0,3) | 3 | CidThe second component is a slot number, not a byte offset within the block. That indirection means tuples can move inside a page without changing their TID, so compacting a page never forces an index rewrite. Chapter 15 covers the details.
Sequential Scan
PAGE_SIZE = 8192
class HeapScan:
"""Scan a heap file block by block."""
def __init__(self, rel, buffer_pool, snapshot):
self.rel, self.pool, self.snapshot = rel, buffer_pool, snapshot
def open(self):
self.blkno = 0
self.slot = 0
self.page = None
def next(self):
while self.blkno < self.rel.n_pages:
if self.page is None:
self.page = self.pool.read_buffer(self.rel, self.blkno) # pinned
self.slot = 0
while self.slot < self.page.n_slots:
slot, raw = self.slot, self.page.item(self.slot)
self.slot += 1
if raw is None: # skip LP_UNUSED / LP_DEAD
continue
tup = parse_tuple(raw)
if not visible(tup, self.snapshot): # MVCC visibility check
continue
return tup, (self.blkno, slot + 1)
self.pool.release_buffer(self.rel, self.blkno) # unpin
self.page = None
self.blkno += 1
return NoneThe visibility check is needed because not every row physically present on a page is visible to you: under MVCC (multiversion concurrency control), several versions of the same logical row coexist on the page.
def visible(tup, snapshot) -> bool:
"""A heavily simplified HeapTupleSatisfiesMVCC."""
if not committed(tup.xmin, snapshot):
return False # insert not committed yet
if tup.xmin >= snapshot.xmax or tup.xmin in snapshot.in_progress:
return False # inserted after our snapshot
if tup.xmax == 0:
return True # never deleted
if not committed(tup.xmax, snapshot):
return True # delete not committed yet
if tup.xmax >= snapshot.xmax or tup.xmax in snapshot.in_progress:
return True # deleted after our snapshot
return FalseBecause this check requires the heap, PostgreSQL's Index Only Scan is harder than it sounds. Index entries carry no visibility information, so in principle the heap must still be consulted. The workaround is the visibility map, an auxiliary file with one bit per block meaning "every tuple on this block is visible to every transaction." When the bit is set, the heap fetch is skipped. VACUUM sets those bits, and any insert, update, or delete on a block clears its bit, so heavily updated tables often fail to get the Index Only Scans you expect.
Index Scans and Bitmap Scans
An index scan walks the index for TIDs and fetches the heap per TID. The problem is that index order (key order) and heap order (physical order) generally differ. Fetching 100 rows can mean 100 random reads, and the same heap page may be read repeatedly. Chapter 7's Mackert–Lohman formula priced that repeated-page effect; here we attack the same problem from the execution side instead.
The bitmap scan fixes this:
- Walk the index to the end, collecting matching TIDs into a bitmap keyed by block number
- Traverse the bitmap in block order and read the heap
Random reads become sequential, and no page is read twice. Multiple indexes can also be combined with bitwise AND / OR.
The catch is work_mem: once the bitmap no longer fits, it degrades to a coarse per-block (lossy) bitmap, and every tuple on those blocks must be rechecked against the predicate. EXPLAIN ANALYZE reports this as Heap Blocks: exact=… lossy=….
Chapter 14: The Buffer Pool — Caching Pages
The execution engine never reads table or index pages straight from disk. Everything goes through the buffer pool.
The pool is an array of fixed-size frames plus a hash table mapping (database, relation, fork, block number) to a frame index.
MAX_USAGE = 5 # PostgreSQL's BM_MAX_USAGE_COUNT
@dataclass
class Frame:
page: object | None = None # a Page object (defined in chapter 15)
tag: tuple | None = None
usage: int = 0
pin: int = 0
dirty: bool = False
class BufferPool:
def __init__(self, n_frames: int, storage):
self.frames = [Frame() for _ in range(n_frames)]
self.table = {} # buffer tag -> frame index
self.hand = 0 # the clock-sweep hand
self.storage = storage
def read_buffer(self, rel, blkno) -> "Page":
tag = (rel.relfilenode, "main", blkno)
idx = self.table.get(tag)
if idx is not None: # ── hit
f = self.frames[idx]
f.usage = min(f.usage + 1, MAX_USAGE)
f.pin += 1
return f.page
idx = self.evict_one() # ── miss
victim = self.frames[idx]
if victim.tag is not None:
if victim.dirty:
old_node, _, old_blkno = victim.tag
self.storage.flush_wal_up_to(victim.page.lsn) # WAL first
self.storage.write_page(old_node, old_blkno, victim.page)
del self.table[victim.tag]
victim.page = Page(self.storage.read_page(rel.relfilenode, blkno))
victim.tag, victim.usage, victim.pin, victim.dirty = tag, 1, 1, False
self.table[tag] = idx
return victim.page
def release_buffer(self, rel, blkno) -> None:
"""Return one pin. At zero the frame becomes replaceable again."""
idx = self.table[(rel.relfilenode, "main", blkno)]
self.frames[idx].pin -= 1
def evict_one(self) -> int:
"""Pick a victim with clock-sweep (second chance)."""
for _ in range(len(self.frames) * (MAX_USAGE + 2)):
f = self.frames[self.hand]
here = self.hand
self.hand = (self.hand + 1) % len(self.frames)
if f.pin > 0:
continue # in use; cannot be evicted
if f.usage > 0:
f.usage -= 1 # give it a second chance
continue
return here
raise RuntimeError("no unpinned buffers available")The Buffer Pool and Clock-Sweep Replacement
Page requests from the execution engine against a four-frame buffer pool, one event at a time. Pinned pages are never evicted, and evicting a dirty page forces a WAL flush first.
The code encodes three rules.
1. Pinned pages cannot be evicted.pin > 0 means somebody currently holds a pointer into that page's memory. Evicting the page would leave that pointer dangling. The executor pins a page while reading tuples from it and calls release_buffer() when moving to the next block. Drop that pairing and you get a buffer leak; eventually every frame is pinned and PostgreSQL fails with ERROR: no unpinned buffers available (the bounded loop in evict_one above reproduces that).
Strict LRU has to relink a list on every hit, which becomes a lock-contention hotspot. Clock-sweep approximates it: keep a reference counter, decrement it when the hand passes, and evict at zero. A hit costs only a counter increment. PostgreSQL's usage_count ranges from 0 to 5.
PostgreSQL also has ring buffers. A sequential scan of a large table (bigger than a quarter of shared_buffers) uses only a small dedicated ring (256 KB by default), so a single sequential scan cannot evict everything useful from the pool.
This is the write-ahead logging (WAL) rule: before a data page is persisted, the WAL records describing the changes to that page must already be persisted. Reverse the order and a crash leaves data newer than the log that produced it, which cannot be recovered.
The rule is also what lets a commit avoid writing data pages at all (NO-FORCE), and lets uncommitted changes be written to data pages (STEAL). STEAL/NO-FORCE is the fastest combination, at the price of needing both REDO and UNDO during recovery.
Chapter 15: The Slotted Page — Where a Row Becomes Bytes
The bottom layer at last: how rows are packed into 8192 bytes.
The Layout
A PostgreSQL heap page is a slotted page.
+--------------------------------------------------+
| PageHeaderData (24B) |
+--------------------------------------------------+
| ItemIdData[0] | ItemIdData[1] | ItemIdData[2] |… | ← grows forward
+--------------------------------------------------+
| |
| free space |
| |
+--------------------------------------------------+
| … | tuple 2 | tuple 1 | tuple 0 | ← grows backward
+--------------------------------------------------+The two areas grow from opposite ends toward the middle. That is the least wasteful way to host a variable-length tuple area and a pointer array in one page, because neither area needs space reserved in advance. pd_lower marks the end of the pointer array and pd_upper the start of the tuple area, so free space is simply pd_upper - pd_lower.
The Slotted Page — Where a Row Lives as Bytes
Watch pd_lower / pd_upper and the line-pointer array move through insert, delete, VACUUM and slot reuse. (A real page is 8192 bytes; here it is shrunk to 256.)
| # | lp_off | lp_len | lp_flags |
|---|---|---|---|
| (empty) | |||
PageInit(page, 256, 0) pd_lower = 24 // right after the header pd_upper = 256 // end of the page
A page starts with its header. The line-pointer array grows forward and tuple bodies grow backward; the region between pd_lower and pd_upper is free space.
Why the Indirection?
The line-pointer array adds an extra hop instead of letting a TID point straight at a byte offset. Chapter 13 gave the reason: tuples must be movable within a page. When gaps left by deletions are compacted (PageRepairFragmentation), byte offsets change but line-pointer numbers do not. Indexes hold TIDs, so without the indirection every compaction would require rewriting the corresponding entries in every index.
ItemIdData is a 4-byte bitfield.
/* src/include/storage/itemid.h */
typedef struct ItemIdData
{
unsigned lp_off:15, /* offset from the start of the page */
lp_flags:2, /* state */
lp_len:15; /* tuple length in bytes */
} ItemIdData;lp_flags has four states:
| Value | Name | Meaning |
|---|---|---|
| 0 | LP_UNUSED | Free; reusable |
| 1 | LP_NORMAL | Points at a live tuple |
| 2 | LP_REDIRECT | Forwards to another line pointer (HOT chains) |
| 3 | LP_DEAD | Dead tuple; the bytes are still there |
LP_REDIRECT supports HOT (Heap-Only Tuple) updates. When an UPDATE touches no indexed column and the new version fits on the same page, no index entry is created. The old line pointer becomes LP_REDIRECT pointing at the new slot, so indexes can still follow the old TID.
Reading a Page
import struct
PAGE_SIZE = 8192
SIZEOF_PAGE_HEADER = 24
SIZEOF_ITEM_ID = 4
LP_UNUSED, LP_NORMAL, LP_REDIRECT, LP_DEAD = 0, 1, 2, 3
def parse_page(buf: bytes):
"""Extract the bytes of every live tuple from an 8192-byte page."""
# PageHeaderData: pd_lsn(8) pd_checksum(2) pd_flags(2)
# pd_lower(2) pd_upper(2) pd_special(2)
# pd_pagesize_version(2) pd_prune_xid(4)
pd_lower, pd_upper = struct.unpack_from("<HH", buf, 12)
n_slots = (pd_lower - SIZEOF_PAGE_HEADER) // SIZEOF_ITEM_ID
for slot in range(n_slots):
(item_id,) = struct.unpack_from(
"<I", buf, SIZEOF_PAGE_HEADER + slot * SIZEOF_ITEM_ID)
# Typical little-endian bitfield packing
lp_off = item_id & 0x7FFF
lp_flags = (item_id >> 15) & 0x0003
lp_len = (item_id >> 17) & 0x7FFF
if lp_flags != LP_NORMAL:
continue
yield slot + 1, buf[lp_off:lp_off + lp_len] # TID offsets are 1-basedpd_lower sits at byte 12 purely because of the sizes of the header fields before it. Counting offsets to decode structs is routine in the storage layer.
Let's wrap that in the object chapters 13 and 14 expect: chapter 14's BufferPool returns a Page, and chapter 13's HeapScan calls page.item(i).
class Page:
def __init__(self, buf: bytes):
self.buf = buf
(pd_lower,) = struct.unpack_from("<H", buf, 12)
self.n_slots = (pd_lower - SIZEOF_PAGE_HEADER) // SIZEOF_ITEM_ID
live = dict(parse_page(buf)) # {line pointer: raw}
# Index i must stay aligned with line pointer i+1; compacting breaks TIDs
self.slots = [live.get(i + 1) for i in range(self.n_slots)]
(self.lsn,) = struct.unpack_from("<Q", buf, 0) # pd_lsn
def item(self, i: int) -> bytes | None:
"""0-based slot index; None for LP_UNUSED / LP_DEAD."""
return self.slots[i]Storage.read_page() returns bytes, so wrapping it on the buffer-pool side as victim.page = Page(self.storage.read_page(…)) is what makes chapter 14's victim.page.lsn resolve.
Inside a Tuple
The fixed part of the tuple header is 23 bytes, but t_hoff (where data begins) is MAXALIGN'd (rounded up to an 8-byte boundary on typical 64-bit platforms), so with no NULLs the effective header is 24 bytes.
HEAP_HASNULL = 0x0001
HEAP_NATTS_MASK = 0x07FF
@dataclass(frozen=True)
class HeapTuple:
xmin: int
xmax: int
n_atts: int
null_bits: bytes
data: bytes
def parse_tuple(raw: bytes) -> HeapTuple:
t_xmin, t_xmax, t_field3 = struct.unpack_from("<III", raw, 0)
# t_ctid occupies bytes 12..17 (BlockIdData 4B + OffsetNumber 2B)
t_infomask2, t_infomask = struct.unpack_from("<HH", raw, 18)
(t_hoff,) = struct.unpack_from("<B", raw, 22)
n_atts = t_infomask2 & HEAP_NATTS_MASK # low 11 bits: attribute count
has_nulls = bool(t_infomask & HEAP_HASNULL)
null_bits = raw[23:t_hoff] if has_nulls else b""
return HeapTuple(t_xmin, t_xmax, n_atts, null_bits, raw[t_hoff:])The NULL bitmap is omitted entirely when no column is NULL. Adding a nullable column does not fatten every row. But one NULL anywhere costs one bit per column, rounded up to whole bytes. With up to 8 columns, that single byte fits into the MAXALIGN padding that already rounds 23 up to 24, so it is effectively free. A 1 bit means NOT NULL and a 0 bit means NULL, which many people find counterintuitive.
Deforming a Tuple
def align_to(offset: int, boundary: int) -> int:
return (offset + boundary - 1) & ~(boundary - 1)
def read_varlena(data: bytes, offset: int) -> tuple[bytes, int]:
"""PostgreSQL variable-length values have 1-byte and 4-byte headers."""
first = data[offset]
if first == 0x01: # external TOAST pointer
raise NotImplementedError("external TOAST pointer")
if first & 0x01: # short value (up to 127 bytes incl. header)
size = (first >> 1) & 0x7F # size includes the header
return data[offset + 1:offset + size], offset + size
# 4-byte header — in this case the value is 4-byte aligned
(header,) = struct.unpack_from("<I", data, offset)
size = (header >> 2) & 0x3FFFFFFF
return data[offset + 4:offset + size], offset + size
def deform_tuple(data: bytes, columns, null_bits: bytes) -> list:
values, offset = [], 0
for i, col in enumerate(columns):
if null_bits and not (null_bits[i // 8] >> (i % 8)) & 1:
values.append(None) # bit 0 → NULL, and no bytes consumed
continue
if col.type == "int":
offset = align_to(offset, 4)
values.append(struct.unpack_from("<i", data, offset)[0])
offset += 4
elif col.type == "text":
# Short varlenas need no alignment; only 4-byte headers do.
if data[offset] & 0x01 == 0:
offset = align_to(offset, 4)
raw, offset = read_varlena(data, offset)
values.append(raw.decode("utf-8"))
else:
raise NotImplementedError(col.type)
return valuesThis is why column order affects performance. deform_tuple walks from the start, so reading column requires computing the offsets of the columns before it, and variable-length or nullable columns make precomputation impossible. PostgreSQL caches offsets only for a leading run of fixed-length, NOT NULL columns.
Alignment padding matters too. Declare (a boolean, b bigint, c boolean, d bigint) and each boolean is followed by 7 bytes of padding to align the bigint. Simply ordering columns from largest type to smallest can shave a dozen bytes per row.
TOAST
A tuple cannot span pages, and PostgreSQL goes further: it tries to keep a tuple within about a quarter of a page so that several rows fit per page. Rows above that limit are handled by TOAST (The Oversized-Attribute Storage Technique). Once a tuple exceeds TOAST_TUPLE_THRESHOLD (2032 bytes by default), large variable-length columns are, in order:
- compressed (
pglzorlz4), and - if still too large, split into chunks stored in a dedicated TOAST table, leaving an 18-byte pointer in the main tuple.
As a result, SELECT id FROM docs (which never touches the TOASTed column) stays fast, while SELECT body FROM docs incurs extra lookups in the TOAST table. Selecting fewer columns pays off here too.
Joining the Layers — A Real-Storage SeqScanExec
Chapter 10's SeqScanExec read a Python list. Now that every piece exists, replace it.
class HeapScanExec:
"""Chapter 10's SeqScanExec, rewired to read real storage."""
def __init__(self, scan: HeapScan, columns, predicate=None):
self.scan, self.columns, self.predicate = scan, columns, predicate
def open(self):
self.scan.open()
def next(self):
while (hit := self.scan.next()) is not None:
tup, tid = hit # chapter 13
row = deform_tuple(tup.data, self.columns, tup.null_bits) # chapter 15
row = dict(zip((c.name for c in self.columns), row))
if self.predicate is None or self.predicate(row) is True:
return row
return None
def close(self):
passNone of the operators above the scan change. NestedLoopJoinExec, HashJoinExec, and TopNExec only ever knew that their child implements open/next/close. That is the concrete payoff of the uniform interface from chapter 10.
The rewiring also shows what happens at the boundary between layers: every next() call unpacks a 48-byte struct into a Python dictionary. Tuple deforming is perennially near the top of a row-store's execution profile for exactly this reason. That cost is why PostgreSQL's JIT compiles tuple deforming along with expression evaluation, and why vectorized engines reach for columnar layouts.
The self.predicate(row) is True spelling is there because of chapter 5's three-valued logic: if UNKNOWN is represented as None, the result must be compared against TRUE rather than used as a truthy value.
Chapter 16: All the Way to Disk
On a buffer-pool miss, we finally read the file.
import os
class Storage:
"""Roughly PostgreSQL's smgr (storage manager)."""
SEGMENT_SIZE = 1 << 30 # split relation files every 1 GB
def __init__(self, base_dir):
self.base_dir, self.fds = base_dir, {}
def _fd(self, relfilenode: int, segno: int) -> int:
key = (relfilenode, segno)
if key not in self.fds:
suffix = "" if segno == 0 else f".{segno}"
path = os.path.join(self.base_dir, f"{relfilenode}{suffix}")
self.fds[key] = os.open(path, os.O_RDWR)
return self.fds[key]
def read_page(self, relfilenode: int, blkno: int) -> bytes:
blocks_per_segment = self.SEGMENT_SIZE // PAGE_SIZE # 131072
segno, local = divmod(blkno, blocks_per_segment)
offset = local * PAGE_SIZE
buf = os.pread(self._fd(relfilenode, segno), PAGE_SIZE, offset)
if len(buf) != PAGE_SIZE:
raise IOError(f"short read at block {blkno}")
return bufThis is the destination. The declarative string SELECT u.name, o.total FROM … becomes, in the end, one system call:
pread(fd=42, buf, 8192, offset=34988032)Block 4271 sits at .
Why Split at 1 GB?
Historically, 32-bit platforms and some file systems capped files at 2 GB. That constraint is gone, but the split remains for practical reasons: unlinking one enormous file when dropping a table can stall a file system. The segment size is configurable at build time with --with-segsize.
Why Not O_DIRECT?
PostgreSQL goes through the kernel page cache by default, meaning double caching: the same page can live in both shared_buffers and the page cache.
That looks wasteful but is deliberate. Using O_DIRECT would require the database to implement readahead, writeback scheduling, and asynchronous I/O itself. Oracle and MySQL/InnoDB do exactly that; PostgreSQL chose to delegate to the kernel. The calculus is shifting as asynchronous I/O (io_uring) lands (see io_uring and epoll).
fsync and Durability
The only thing that must be durable at commit time is the WAL.
def commit(self, xid: int) -> None:
self.wal.append(CommitRecord(xid))
os.write(self.wal_fd, self.wal.pending_bytes())
os.fsync(self.wal_fd) # ← no OK to the client until this returnsWhen write(2) returns, nothing is guaranteed about durability: the data has merely reached the kernel page cache. Only fsync(2) guarantees it reached the storage device, and even then a device write cache can undermine that.
How long fsync takes depends on the storage device and its write cache, anywhere from microseconds to milliseconds; pg_test_fsync measures it on a given system. Calling it once per transaction caps throughput at the fsync rate, which is why group commit exists: it batches several transactions' WAL into one fsync. PostgreSQL's knobs for it are commit_delay / commit_siblings.
With synchronous_commit = off, the server replies to the client without waiting for fsync. Latency improves greatly, at the risk of losing a few hundred milliseconds of committed transactions in a crash. This is the speed-versus-durability trade-off in its plainest form.
Chapter 17: Putting It Together
All the pieces, wired into one function:
PLAN_CACHE: dict[str, object] = {}
def execute(sql: str, catalog, stats, pool, snapshot, params=None):
# ── front end
tokens = tokenize(sql) # ch. 1
ast = Parser(tokens).parse_select() # ch. 2
binder = Binder(catalog)
binder.collect_from(ast.frm) # ch. 3
bound = bind_statement(binder, ast)
# ── planner (chapter 9's plan cache short-circuits identical SQL;
# simplified — the first plan is kept, with no custom/generic re-evaluation)
if sql not in PLAN_CACHE:
logical = to_logical_plan(bound) # ch. 4
logical = rewrite(logical) # ch. 5
estimated = estimate_cardinality(logical, stats, params) # ch. 6
PLAN_CACHE[sql] = optimize(estimated, stats) # ch. 7–9
physical = PLAN_CACHE[sql]
# ── executor
op = build_executor(physical, pool, snapshot) # ch. 10–15
op.open()
try:
while (row := op.next()) is not None:
yield row
finally:
op.close()Listing the input and output of each stage shows the whole article as a chain of types:
str
→ list[Token]
→ Select (unbound AST)
→ Select (bound; ColRef became Var)
→ LogicalPlan (relational algebra)
→ LogicalPlan (after rewriting)
→ LogicalPlan + row estimates
→ PhysicalPlan (algorithms chosen)
→ Operator (an executable open/next/close tree)
→ Iterator[dict]Each layer exists only for the next one and knows nothing across the boundary. That separation is what makes it possible to swap the execution engine from Volcano to vectorized without touching the parser.
Checking the Answers Against EXPLAIN
One last exercise: the execution plan for the running example. Most of the numbers in it map to a chapter of this article.
(The output below is an illustrative example, not a measurement. The costs are this article's model rendered in EXPLAIN's format; the run-time figures, such as actual times and rows, buffer counts, and planning and execution times, were chosen to fit the running example. Real PostgreSQL uses for a top-N sort, so its Sort startup cost comes out slightly higher.)
Limit (cost=26601.03..26601.06 rows=10 width=36) (actual time=241.88..241.89 rows=10 loops=1)
Buffers: shared hit=1024 read=9976
-> Sort (cost=26601.03..26738.53 rows=55000 width=36) (actual time=241.88..241.88 rows=10 loops=1)
Sort Key: o.total DESC
Sort Method: top-N heapsort Memory: 27kB
-> Hash Join (cost=2387.50..25687.50 rows=55000 width=36) (actual time=18.40..228.12 rows=54812 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=1024 read=9976
-> Seq Scan on orders o (cost=0.00..22500.00 rows=100000 width=20) (actual time=0.02..191.44 rows=99873 loops=1)
Filter: (total > '100'::numeric)
Rows Removed by Filter: 900127
Buffers: shared hit=24 read=9976
-> Hash (cost=2250.00..2250.00 rows=55000 width=24) (actual time=18.30..18.30 rows=54903 loops=1)
Buckets: 65536 Batches: 1 Memory Usage: 3584kB
-> Seq Scan on users u (cost=0.00..2250.00 rows=55000 width=24) (actual time=0.01..9.88 rows=54903 loops=1)
Filter: (age > 30)
Rows Removed by Filter: 45097
Buffers: shared hit=1000 read=0
Planning Time: 0.284 ms
Execution Time: 241.94 ms| Fragment | Where it came from |
|---|---|
'100'::numeric | Ch. 3 — the binder's implicit cast of the integer literal |
Filter: sitting on the scan rather than the join | Ch. 5 — predicate pushdown |
rows=55000 | Ch. 6 — selectivity_gt = 0.55 × 100,000 |
rows=100000 vs actual rows=99873 | Ch. 6 — a 0.13% error; ch. 7's ranking survives it easily |
cost=0.00..22500.00 | Ch. 7 — |
cost=2387.50..25687.50 | Ch. 7 — startup carries the build side's entire total cost |
the separate Hash node | Ch. 10 — the build phase of HashJoinExec.open() |
Batches: 1 | Ch. 11 — it fit in the hash budget; 2 or more means grace hash join |
Memory Usage: 3584kB | Ch. 11 — under the 8 MB hash budget (work_mem 4 MB × hash_mem_multiplier 2.0), so no spill |
Sort Method: top-N heapsort | Ch. 9 — LIMIT picked the algorithm; ch. 10's TopNExec implements it |
| Ch. 9 — | |
shared hit=1024 read=9976 | Ch. 14 — 1,024 pages resident, the rest are misses |
read=9976 | Ch. 16 — 9,976 shared-buffer block reads from storage; not necessarily one syscall per block |
Planning Time: 0.284 ms | Ch. 9 — exactly what the plan cache removes |
To catch memory spills in operation, watch two lines. If Batches: 1 has become Batches: 8, or Sort Method: quicksort has become external merge, chapter 11 has kicked in.
Chapter 18: How Real Databases Differ
Everything above leaned on PostgreSQL. Other databases make different design decisions.
| PostgreSQL | SQLite | MySQL / InnoDB | DuckDB | |
|---|---|---|---|---|
| Optimization | Bottom-up DP (+ GEQO) | Cost-based, greedy search (NGQP) | Cost-based | Bottom-up DP (DPhyp) |
| Execution model | Volcano (JIT for expressions) | Bytecode VM | Volcano (hash join since 8.0) | Vectorized (columnar batches) |
| Row storage | Heap + non-clustered indexes | Records stored in a B-tree | Clustered on the primary key | Columnar (row groups) |
| MVCC | Multiple versions on the page | Rollback journal / WAL | UNDO log + read view | Local delta based |
| Page size | 8 KB (build-time option) | 4 KB (default) | 16 KB (default) | 256 KB blocks |
SQLite's execution model is not Volcano but a bytecode VM of its own (a prepared statement is a VDBE program). EXPLAIN prints instructions like OpenRead, Rewind, Column, Next. Loops are explicit jumps rather than nested iterators (see Inside Bytecode VMs).
InnoDB's clustered index stores the row data itself in the leaves of the primary-key B+ tree. Secondary index leaves therefore hold primary key values, not TIDs, and reading a column that the secondary index does not contain takes a two-level search: secondary index, then primary index. PostgreSQL's indexes, by contrast, all hold TIDs that point into the heap.
DuckDB is columnar and vectorized, quite far from the tuple-at-a-time world described here. Yet its front half (a parser forked from PostgreSQL's grammar, a logical plan, DP-based join ordering) closely resembles PostgreSQL's, which suggests the layering works.
Summary
The main points from tracing one SQL statement down to bytes:
The system is a chain of transformations. String → tokens → AST → bound AST → logical plan → physical plan → execution tree. Each stage consumes only its predecessor's output and produces only its successor's input.
Meaning is assigned at binding time. Names become entities during semantic analysis. Everything after that operates on a normalized representation.
Optimization is a bet on cardinality estimation. No amount of cost-model precision survives a row estimate that is two orders of magnitude off. Most "the query suddenly got slow" incidents live here.
Cost is a pair of numbers. Because startup and total cost are tracked separately, a parent that does not want every row (a LIMIT, a cursor) can invert the algorithm choice underneath it. A blocking operator's startup cost includes the whole cost of the input it drains first; for Sort, the two numbers nearly coincide.
Estimation accuracy and plan reuse are inherently in tension. A generic plan built without parameter values saves planning time at the cost of accuracy; a plan per value is accurate but uncacheable. "It was fast for the first few calls and then fell off a cliff" is that tension surfacing.
The execution model rests on a uniform interface. The open/next/close contract is what allows arbitrary operator composition, and it is also the source of per-tuple overhead. Vectorization wins performance by coarsening the granularity of that contract, and code generation by removing it.
Operators degrade differently when memory runs out. Sorting degrades gracefully past work_mem by switching to an external merge. Hashing falls off a cliff: a hash join repartitions both of its inputs, and a hash aggregate recursively repartitions its single one.
NULL brings a third truth value, and it reaches every layer. Three-valued logic constrains which rewrites are legal. That WHERE discards UNKNOWN, that NOT IN cannot become an anti join, that predicates cannot always be pushed below an outer join: all of it follows from those truth tables.
The physical layer buys flexibility through indirection. Because a TID names a slot, tuples can move inside a page without rewriting indexes. The buffer pool is another indirection, between the executor and the disk.
Durability rides entirely on WAL ordering. A single rule (persist the log before the data page) is what makes the fast STEAL/NO-FORCE buffer policy safe.
EXPLAIN (ANALYZE, BUFFERS) should read differently now. The Hash node under Hash Join is a blocking operator; the left-hand number in cost=2387.50..25687.50 is chapter 7's startup cost; rows=55000 is the chapter 6 estimate; and Buffers: shared hit=1024 read=9976 is chapter 14's buffer pool at work: 9,976 block reads from storage, which need not map one-to-one to pread(2) calls.
References
Papers
- Selinger, P. G. et al. Access Path Selection in a Relational Database Management System. SIGMOD 1979. — The origin of cost-based optimization and join-order DP, including interesting orders.
- Mackert, L. F., Lohman, G. M. Index Scans Using a Finite LRU Buffer: A Validated I/O Model. ACM TODS 14(3):401–424, 1989. — The source of chapter 7's
index_pages_fetched. - Graefe, G. Volcano — An Extensible and Parallel Query Evaluation System. IEEE TKDE 1994. — The iterator model, formalized. First presented as Encapsulation of Parallelism in the Volcano Query Processing System, SIGMOD 1990.
- Graefe, G. The Cascades Framework for Query Optimization. IEEE Data Eng. Bull. 1995. — Rule-based top-down optimization.
- Boncz, P., Zukowski, M., Nes, N. MonetDB/X100: Hyper-Pipelining Query Execution. CIDR 2005. — The starting point of vectorized execution.
- Neumann, T. Efficiently Compiling Efficient Query Plans for Modern Hardware. VLDB 2011. — Push-based code generation.
- Moerkotte, G., Neumann, T. Analysis of Two Existing and One New Dynamic Programming Algorithm for the Generation of Optimal Bushy Join Trees without Cross Products. VLDB 2006. — DPccp (connected-subgraph-complement-pair enumeration).
- Moerkotte, G., Neumann, T. Dynamic Programming Strikes Back. SIGMOD 2008. — DPhyp, the hypergraph generalization.
- Leis, V. et al. How Good Are Query Optimizers, Really? PVLDB 9(3):204–215, 2015. — Measured cardinality estimation error.
- Mohan, C. et al. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS 1992. — The standard formulation of WAL and recovery.
Source Code
src/backend/parser/gram.y— PostgreSQL's grammar (Bison)src/backend/optimizer/path/joinrels.c— Join-order searchsrc/backend/optimizer/path/costsize.c— The cost modelsrc/backend/utils/adt/selfuncs.c— Selectivity estimationsrc/backend/executor/execMain.c— The executor entry pointsrc/backend/storage/buffer/freelist.c— Clock-sweepsrc/include/storage/bufpage.h— Page layout definitionssrc/include/access/htup_details.h— HeapTupleHeader definitions
Books
- Hellerstein, J. M., Stonebraker, M., Hamilton, J. Architecture of a Database System. Foundations and Trends in Databases, 2007.
- Moerkotte, G. Building Query Compilers. (Draft available online.)
- Petrov, A. Database Internals. O'Reilly, 2019.