Ask "is Boise in North America?" of a location hierarchy and the natural two-level join answers no — a confident, silent wrong answer, because a join follows exactly one edge and Boise is four hops deep. WITH RECURSIVE gets it right at any depth. Transitive reachability can't be a fixed-shape query.
Store a location hierarchy the obvious way — each place records the place it is within (Boise → Ada County → Idaho → United States → North America) — and ask "is Boise in North America?" A SQL join follows exactly one edge, so the natural query bakes in a fixed number of hops. You will write the sensible two-level join, watch it answer "Boise is not in North America" — a confident, silent wrong answer — and then watch WITH RECURSIVE get it right at any depth. The lesson: transitive reachability can't be expressed by a fixed-shape query, no matter how many joins you add.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 3 — Data Models and Query Languages, §"Datalog: Recursive Relational Queries" (and §"Graph Queries in SQL"):
recursive queries on graphs are a particular strength of Datalog.
The book shows the same idea in SQL via recursive common table expressions (Example 3-6) and illustrates transitive within reachability with Figure 3-7, "Determining that Idaho is in North America." Here you make the fixed-join version fail on purpose, then fix it with recursion.
The surprise is about what a join can and cannot express. These help; each line notes where it shows up.
within_id points at the parent row). One table encodes a whole tree/graph.child JOIN parent ON child.within_id = parent.id walks exactly one hop up the tree. k hops means k joins written into the query. The query's shape fixes the traversal depth.WITH RECURSIVE) — an anchor row set UNION ALL a recursive term that references the CTE itself, iterated until no new rows appear (a fixpoint). This is the tool that escapes the fixed-shape limit.WITH RECURSIVE section walks the anchor + recursive-term structure with a graph example. Adjacency list vs. alternatives (#1, #3): search "adjacency list vs closure table SQL hierarchy" — the closure table is the denormalized way to make reachability a plain join (a Go-deeper below). The graph framing (#2–#3): DDIA's own Cypher examples (3-4/3-5) show the variable-length path operator -[:WITHIN*]-> that says "any number of hops" directly — the thing SQL needs WITH RECURSIVE to express. If only one is new, make it #4 — it's the whole fix.
postgres:16 image (PostgreSQL 16.14), on one locations table of 10 rows (a small hierarchy of varying depth).
The locations table is an adjacency list: each row names a place and points at the place it is within. That encodes a tree:
North America
└─ United States
├─ Idaho
│ └─ Ada County
│ └─ Boise
└─ California
└─ Los Angeles
A single join climbs one level of that tree. To ask "everything within North America," you need to climb all the way down — but the number of levels (Boise is 4 below North America; California is 2) is a fact about the data, and a SQL join count is fixed when you write the query. That mismatch is the whole exercise: a fixed number of joins answers for a fixed depth, and silently misses anything deeper.
# start Postgres 16 (first run pulls the image)
docker run -d --name ddia-ch3 -e POSTGRES_PASSWORD=study -e POSTGRES_DB=study postgres:16
sleep 3
# load the 10-row hierarchy (setup may be scripted -- the queries may not)
docker cp recursion_setup.sql ddia-ch3:/tmp/
docker exec ddia-ch3 psql -U postgres -d study -f /tmp/recursion_setup.sql
# open an interactive session and type the steps below by hand
docker exec -it ddia-ch3 psql -U postgres -d study
Predict. What does a single self-join return for "what is directly within the United States?"
SELECT child.name AS directly_within_us
FROM locations child
JOIN locations parent ON child.within_id = parent.id
WHERE parent.name = 'United States'
ORDER BY child.name;
Idaho and California — the two direct children. One join expresses exactly one level of containment. This is all a join ever does.
Predict. Now list everything within North America. The natural approach: join down a couple of levels (children, then grandchildren). There are 6 places under North America — how many does this two-level join return, and is Boise one of them?
SELECT l1.name AS place, 1 AS hops_below_na
FROM locations l1
JOIN locations na ON l1.within_id = na.id
WHERE na.name = 'North America'
UNION ALL
SELECT l2.name, 2
FROM locations l2
JOIN locations l1 ON l2.within_id = l1.id
JOIN locations na ON l1.within_id = na.id
WHERE na.name = 'North America'
ORDER BY hops_below_na, place;
Only 3 of the 6. The join reaches exactly two levels down and stops — Ada County, Los Angeles, and Boise (all deeper) are simply gone. And it doesn't error; it returns a tidy, confident, incomplete result. Ask it directly:
SELECT count(*) AS boise_found_by_fixed_join
FROM (
SELECT l1.name FROM locations l1 JOIN locations na ON l1.within_id = na.id
WHERE na.name = 'North America'
UNION ALL
SELECT l2.name FROM locations l2 JOIN locations l1 ON l2.within_id = l1.id
JOIN locations na ON l1.within_id = na.id WHERE na.name = 'North America'
) t
WHERE name = 'Boise';
The query says Boise is not in North America. It is — Boise is 4 hops down. You could add a third join, and a fourth, but the tree's depth is data; whatever number you pick is wrong for something deeper.
Predict. WITH RECURSIVE starts at North America and re-applies the same one-hop join until nothing new appears. How many places now, and does it find Boise?
WITH RECURSIVE within_na AS (
SELECT id, name, within_id, 0 AS hops_below_na
FROM locations WHERE name = 'North America'
UNION ALL
SELECT l.id, l.name, l.within_id, w.hops_below_na + 1
FROM locations l
JOIN within_na w ON l.within_id = w.id
)
SELECT name AS place, hops_below_na
FROM within_na
WHERE name <> 'North America'
ORDER BY hops_below_na, place;
All 6, down to Boise at 4 hops — and the query never mentions the number 4. The recursive version answers "is Boise in North America?" correctly:
WITH RECURSIVE within_na AS (
SELECT id, name, within_id FROM locations WHERE name = 'North America'
UNION ALL
SELECT l.id, l.name, l.within_id FROM locations l
JOIN within_na w ON l.within_id = w.id
)
SELECT count(*) AS boise_found_by_recursion FROM within_na WHERE name = 'Boise';
WITH RECURSIVE returns all 6 (Boise at depth 4) and finds Boise (1) — the same query regardless of how deep the tree goes.A SQL join follows exactly one edge. child JOIN parent ON child.within_id = parent.id is a single hop up the tree, so k levels of containment require k joins physically written into the query. The shape of the query — how many joins it contains — hard-codes a traversal depth at the moment you type it.
But "is X within Y?" is a question about a path whose length lives in the data: Boise → Ada County → Idaho → United States → North America is four edges; California → United States → North America is two. No single join count matches every path, so any fixed-shape query is correct only for the depths it happens to enumerate and quietly wrong for the rest. That is why Step 2 didn't throw an error — it answered a different, shallower question ("within 2 hops") and returned 0 for Boise. The dangerous failures here are false negatives that pass every test written on shallow data and then miss deeper rows in production.
WITH RECURSIVE removes the fixed shape. It seeds the result with the anchor (North America), then repeatedly applies the same one-hop join to whatever it found last round, stopping only when a round adds nothing new — a fixpoint. One query text, unbounded depth. This is the transitive closure of the within relation, and computing it is inherently iterative: it is exactly what the book means when it says recursive queries on graphs are a particular strength of Datalog, and why graph query languages like Cypher give you a variable-length path operator (-[:WITHIN*]->, "any number of WITHIN edges") as a first-class primitive.
('North Boise' within 'Boise') and re-run Step 2. The fixed join's answer doesn't change; the recursive one now returns 7. Concrete proof the fixed shape tracks the query, not the data.born_in and lives_in edges, then write the book's "who emigrated from the US to Europe?" — born_in something within the US and lives_in something within Europe. It needs the recursive within set twice.location_ancestors(descendant, ancestor) table (every place paired with every ancestor). Now "is Boise in North America?" is a single indexed lookup — no recursion at query time. What did you trade away? (Every insert/move must maintain the closure — the denormalization tradeoff from the other Ch. 3 exercises, again.)Sources: DDIA 2e, Ch. 3, §"Datalog: Recursive Relational Queries" and §"Graph Queries in SQL" (Example 3-6, Figure 3-7) · PostgreSQL manual, "WITH Queries (Common Table Expressions)."