Database Management Systems (DBMS) Module 5 – SQL semantics: how queries really evaluate
Recursive queries: WITH RECURSIVE and Datalog
How WITH RECURSIVE repeats a join until nothing new appears, how to stop it on cycles, and why recursion goes beyond relational algebra, shown with Datalog.
What you will learn
- Write recursive CTEs with an anchor and a recursive member that terminate
- Handle cycles with UNION, depth limits or PostgreSQL's CYCLE clause
- Explain why recursion goes beyond relational algebra, using Datalog rules
Before you start
What you’ll learn · Before you start
What you will learn
- Write recursive CTEs with an anchor and a recursive member that terminate
- Handle cycles with UNION, depth limits or PostgreSQL's CYCLE clause
- Explain why recursion goes beyond relational algebra, using Datalog rules
Before you start
On this page
A recursive query repeats a join until it finds nothing new. In SQL it is a common table expression written
WITH RECURSIVE, made of two parts joined by UNION or UNION ALL: an anchor, the rows to start from, and a
recursive member, which refers to the expression’s own name and goes one step further from the rows found so far.
The database runs the anchor, then runs the recursive member again and again on the rows of the previous step, and
stops when a step adds no row. That answers questions whose depth is not known in advance: every city reachable by
train, everyone who reports to a manager, every part inside an assembly. Relational algebra cannot express them,
because each algebra expression joins a fixed number of times. Datalog, a language of facts and rules, has
recursion at its centre, and the way Datalog is evaluated explains what WITH RECURSIVE does.
Every SQLite query here, and the Python program of the Datalog section, can be run from this page (SQLite 3.49.1 in
sql.js 1.14.2, Python in Pyodide). PostgreSQL’s SEARCH and CYCLE clauses, which SQLite lacks, and its rules for
the recursive part are shown as the output psql printed on PostgreSQL 18.6.
Questions with no fixed depth
Take a table of direct trains, one row per pair of cities. “Where can I go from Chennai with one train?” is a
selection. “With two trains?” joins routes with itself once. Any fixed number of trains is a fixed number of joins,
and a union of a few such expressions covers “at most three trains”. But “with any number of trains” would need as
many joins as the longest trip in the data, and that number is not known when the query is written. Aho and Ullman
proved that no relational algebra expression computes this transitive closure of a relation in general. A
recursive query does:
-- Direct trains between cities, one row per pair. The data has two cycles.
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Chennai', 'Bengaluru'), ('Bengaluru', 'Mysuru'), ('Mysuru', 'Chennai'),
('Bengaluru', 'Hubballi'), ('Hubballi', 'Pune'), ('Pune', 'Mumbai'),
('Mumbai', 'Pune'), ('Kolkata', 'Bhubaneswar');
WITH RECURSIVE reach(city) AS (
SELECT 'Chennai' -- the anchor: where the search starts
UNION -- keeps only rows not produced before
SELECT r.dst -- the recursive part: one more train
FROM routes AS r JOIN reach ON r.src = reach.city
)
SELECT city FROM reach ORDER BY city; Output
┌───────────┐ │ city │ ├───────────┤ │ Bengaluru │ │ Chennai │ │ Hubballi │ │ Mumbai │ │ Mysuru │ │ Pune │ └───────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < reachable.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
- The anchor is the single row
Chennai. The recursive member joins the rows found so far withroutesand returns the cities one more train away. - The data has two cycles, Chennai, Bengaluru and Mysuru back to Chennai, and Pune to Mumbai and back. The query
still ends, because
UNIONkeeps only rows it has not produced before, so a city reached a second time adds nothing. - Kolkata and Bhubaneswar are linked to each other but not to Chennai, so they never appear.
How WITH RECURSIVE runs
The query is written recursively, but PostgreSQL’s manual points out that it is evaluated by iteration. The evaluation keeps two things: the result so far, and a working table holding the rows of the last step.
- The anchor runs once. Its rows (without duplicates, under
UNION) fill both the result and the working table. - The recursive member then runs against the working table alone. Under
UNION, every row it returns that is already in the result, or repeated in the same step, is thrown away. The rest are added to the result and become the new working table. - When a step returns nothing new, the working table is empty and the evaluation is over.
Each step joins only the rows of the step before
Text description of the diagram
The diagram is a column of boxes, one per step of the recursive query that finds every city reachable from Chennai, joined by arrows labelled "join routes".
- The anchor puts Chennai in the working table.
- Step 1 joins the working table with routes and finds Bengaluru, which is new.
- Step 2 starts from Bengaluru and finds Hubballi and Mysuru, both new.
- Step 3 starts from Hubballi and Mysuru. Mysuru leads back to Chennai, which is already in the result, so UNION drops it; Hubballi leads to Pune, which is new.
- Step 4 starts from Pune and finds Mumbai, which is new.
- Step 5 starts from Mumbai, which leads back to Pune. Pune is already in the result, so it is dropped, the step adds nothing new, and the recursion stops.
The result is every row that any step kept: six cities, Chennai, Bengaluru, Hubballi, Mysuru, Pune and Mumbai. Kolkata and Bhubaneswar are never reached.
The key detail is that each step reads only the rows of the step before, never the whole result again. That keeps the work proportional to the rows that are new, and it is the idea Datalog calls semi-naive evaluation, which the last section measures.
SQLite reaches the same rows in a slightly different way. It keeps a queue of rows: it removes one, runs the
recursive member with that single row standing in for the whole table, and appends whatever comes back. Without an ORDER BY in the
recursive member the queue is first in, first out, which the manual says you should not rely on.
Cycles: when UNION stops the recursion and when it does not
UNION stopped the first query because its rows were just city names, and a city found again is an exact repeat. Add
a column that changes on every step, such as the number of trains so far, and no row is ever an exact repeat:
(Mysuru, 2), (Mysuru, 5) and (Mysuru, 8) are all different. The query then runs for ever, so this one carries a
guard:
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Chennai', 'Bengaluru'), ('Bengaluru', 'Mysuru'), ('Mysuru', 'Chennai'),
('Bengaluru', 'Hubballi'), ('Hubballi', 'Pune'), ('Pune', 'Mumbai'),
('Mumbai', 'Pune'), ('Kolkata', 'Bhubaneswar');
-- With a train count in every row, a row is never repeated around a cycle, so
-- UNION no longer ends the recursion. The guard trains < 12 is what ends it.
WITH RECURSIVE trip(city, trains) AS (
SELECT 'Chennai', 0
UNION
SELECT r.dst, trip.trains + 1
FROM routes AS r JOIN trip ON r.src = trip.city
WHERE trip.trains < 12
)
SELECT count(*) AS rows_made, count(DISTINCT city) AS cities FROM trip;
-- The fewest trains to each city: the same rows, reduced afterwards.
WITH RECURSIVE trip(city, trains) AS (
SELECT 'Chennai', 0
UNION
SELECT r.dst, trip.trains + 1
FROM routes AS r JOIN trip ON r.src = trip.city
WHERE trip.trains < 12
)
SELECT city, min(trains) AS fewest_trains
FROM trip GROUP BY city ORDER BY fewest_trains, city;
-- How many rows the guarded query makes for four different guards.
WITH RECURSIVE trip(guard, city, trains) AS (
SELECT column1, 'Chennai', 0 FROM (VALUES (6), (12), (24), (48))
UNION
SELECT trip.guard, r.dst, trip.trains + 1
FROM routes AS r JOIN trip ON r.src = trip.city
WHERE trip.trains < trip.guard
)
SELECT guard, count(*) AS rows_made FROM trip GROUP BY guard ORDER BY guard; Output
┌───────────┬────────┐ │ rows_made │ cities │ ├───────────┼────────┤ │ 34 │ 6 │ └───────────┴────────┘ ┌───────────┬───────────────┐ │ city │ fewest_trains │ ├───────────┼───────────────┤ │ Chennai │ 0 │ │ Bengaluru │ 1 │ │ Hubballi │ 2 │ │ Mysuru │ 2 │ │ Pune │ 3 │ │ Mumbai │ 4 │ └───────────┴───────────────┘ ┌───────┬───────────┐ │ guard │ rows_made │ ├───────┼───────────┤ │ 6 │ 14 │ │ 12 │ 34 │ │ 24 │ 74 │ │ 48 │ 154 │ └───────┴───────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < hops.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The guard trip.trains < 12 is what ended the first query, after 34 rows for 6 cities. The second query keeps the
smallest count per city, which is what a journey planner wants. The third query runs the same recursion with four
guards at once and shows the price of guarding instead of detecting the cycle: the rows grow in step with the guard,
from 14 for a guard of 6 to 154 for a guard of 48, although there are still only 6 cities. A guard is a safety net
against runaway data, and a limit you have to choose; it is no substitute for knowing when a trip repeats itself.
To detect the repetition, carry the path in each row and refuse to step onto a city that is already on it. This version adds one more train, from Mysuru to Hubballi, and lists every trip from Chennai to Mumbai that visits no city twice:
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Chennai', 'Bengaluru'), ('Bengaluru', 'Mysuru'), ('Mysuru', 'Chennai'),
('Bengaluru', 'Hubballi'), ('Hubballi', 'Pune'), ('Pune', 'Mumbai'),
('Mumbai', 'Pune'), ('Kolkata', 'Bhubaneswar'), ('Mysuru', 'Hubballi');
-- Carry the path taken so far, and never step onto a city that is already on it.
WITH RECURSIVE trip(city, path) AS (
SELECT 'Chennai', '/Chennai/'
UNION ALL
SELECT r.dst, trip.path || r.dst || '/'
FROM routes AS r JOIN trip ON r.src = trip.city
WHERE instr(trip.path, '/' || r.dst || '/') = 0
)
SELECT path FROM trip WHERE city = 'Mumbai' ORDER BY length(path), path; Output
┌─────────────────────────────────────────────────┐ │ path │ ├─────────────────────────────────────────────────┤ │ /Chennai/Bengaluru/Hubballi/Pune/Mumbai/ │ │ /Chennai/Bengaluru/Mysuru/Hubballi/Pune/Mumbai/ │ └─────────────────────────────────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < paths.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
PostgreSQL 14 added the SQL standard’s built-in form of the same idea. CYCLE city SET is_cycle USING path tracks the
path in an array, sets is_cycle to true on the first row that returns to a city already on its path, and does not
extend that row. SEARCH BREADTH FIRST BY city SET visit_order adds a column that sorts the result by distance from
the start (DEPTH FIRST sorts it like a walk down each branch):
# PostgreSQL 18: the SQL-standard CYCLE and SEARCH clauses of a recursive query.
from pg_session import psql
psql("""
CREATE TABLE routes (src text NOT NULL, dst text NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Chennai', 'Bengaluru'), ('Bengaluru', 'Mysuru'), ('Mysuru', 'Chennai'),
('Bengaluru', 'Hubballi'), ('Hubballi', 'Pune'), ('Pune', 'Mumbai'),
('Mumbai', 'Pune'), ('Kolkata', 'Bhubaneswar');
-- UNION ALL with no guard of our own: CYCLE marks a row that returns to a city
-- already on its path, and does not extend that row any further.
WITH RECURSIVE trip(city, trains) AS (
SELECT 'Chennai', 0
UNION ALL
SELECT r.dst, t.trains + 1 FROM routes AS r JOIN trip AS t ON r.src = t.city
) CYCLE city SET is_cycle USING path
SELECT city, trains, is_cycle, path FROM trip ORDER BY trains, city;
-- SEARCH BREADTH FIRST adds a column to sort by: nearer cities first.
WITH RECURSIVE trip(city, trains) AS (
SELECT 'Chennai', 0
UNION ALL
SELECT r.dst, t.trains + 1 FROM routes AS r JOIN trip AS t ON r.src = t.city
) SEARCH BREADTH FIRST BY city SET visit_order
CYCLE city SET is_cycle USING path
SELECT city, trains FROM trip WHERE NOT is_cycle ORDER BY visit_order;
""") Output
CREATE TABLE
INSERT 0 8
city | trains | is_cycle | path
-----------+--------+----------+-----------------------------------------------------------
Chennai | 0 | f | {(Chennai)}
Bengaluru | 1 | f | {(Chennai),(Bengaluru)}
Hubballi | 2 | f | {(Chennai),(Bengaluru),(Hubballi)}
Mysuru | 2 | f | {(Chennai),(Bengaluru),(Mysuru)}
Chennai | 3 | t | {(Chennai),(Bengaluru),(Mysuru),(Chennai)}
Pune | 3 | f | {(Chennai),(Bengaluru),(Hubballi),(Pune)}
Mumbai | 4 | f | {(Chennai),(Bengaluru),(Hubballi),(Pune),(Mumbai)}
Pune | 5 | t | {(Chennai),(Bengaluru),(Hubballi),(Pune),(Mumbai),(Pune)}
(8 rows)
city | trains
-----------+--------
Chennai | 0
Bengaluru | 1
Hubballi | 2
Mysuru | 2
Pune | 3
Mumbai | 4
(6 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cycle.py
The first query uses UNION ALL and no guard of its own, yet it ends: the two rows marked t are the moments a trip
came back to Chennai and to Pune, and nothing grew from them. The second query drops those rows and lists each city
once, nearest first. SQLite does not have these clauses:
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL);
-- PostgreSQL's standard CYCLE clause: SQLite does not know it.
WITH RECURSIVE trip(city) AS (
SELECT 'Chennai'
UNION ALL
SELECT r.dst FROM routes AS r JOIN trip ON r.src = trip.city
) CYCLE city SET is_cycle USING path
SELECT * FROM trip; Output (exit status 1)
Printed as an error (standard error)
Parse error near line 4: near "CYCLE": syntax error
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < no_cycle_clause.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
PostgreSQL also restricts what the recursive member may contain, and it stops evaluating when the outer query has read enough rows:
# What PostgreSQL 18 refuses in the recursive part of a query, and one way to stop a runaway query.
from pg_session import psql
psql("""
CREATE TABLE routes (src text NOT NULL, dst text NOT NULL);
INSERT INTO routes VALUES ('Chennai', 'Bengaluru'), ('Bengaluru', 'Mysuru');
-- An aggregate over the rows of the step itself.
WITH RECURSIVE reach(city, n) AS (
SELECT 'Chennai', 1
UNION
SELECT r.dst, count(*) FROM routes AS r JOIN reach ON r.src = reach.city GROUP BY r.dst
)
SELECT * FROM reach;
-- Negation of the query's own result: "a city not reached yet".
WITH RECURSIVE far(city) AS (
SELECT 'Chennai'
UNION
SELECT r.dst FROM routes AS r WHERE NOT EXISTS (SELECT 1 FROM far WHERE far.city = r.dst)
)
SELECT * FROM far;
-- No end condition at all: LIMIT in the outer query stops the evaluation here.
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n)
SELECT i FROM n LIMIT 3;
""") Output
CREATE TABLE
INSERT 0 2
ERROR: aggregate functions are not allowed in a recursive query's recursive term
LINE 4: SELECT r.dst, count(*) FROM routes AS r JOIN reach ON r....
^
ERROR: recursive reference to query "far" must not appear within a subquery
LINE 4: ... FROM routes AS r WHERE NOT EXISTS (SELECT 1 FROM far WHERE ...
^
i
---
1
2
3
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 limits.py
An aggregate in the recursive member is refused, because it would summarise a step while that step is still being
computed; so is a subquery that refers to the expression’s own name, such as NOT EXISTS (… FROM far …), which would
ask “not reached yet” about a result that is still growing. The last query has no end condition at all, and LIMIT 3
stops it because PostgreSQL computes only as many rows as the outer query fetches. The manual recommends this only
for testing: other engines may compute everything first, and a sort or a join in the outer query reads all the rows
anyway.
| Criterion | UNION alone | A guard | A path check or CYCLE |
|---|---|---|---|
| Ends on a cycle | Only if no row holds a counter or path | Yes, at the limit | Yes |
| Rows | Each row once | Grow with the limit | One per simple path |
| Equal rows from two paths | Merged | Kept | Kept |
| Path shown | No | No | Yes |
| Engines | All | All | Path: all; CYCLE: PostgreSQL 14+ |
| When to choose | Which nodes are reachable, not how | A known depth bound, or a safety net | Routes and chains, with the path itself |
Hierarchies and bills of materials
The same pattern walks a tree. A reporting line needs no cycle check, as long as the data really is a tree, and
SQLite has one extra feature that suits it: an ORDER BY in the recursive member chooses which queued row is expanded
next. Sorting by level, deepest first, turns the walk into a depth-first one, so each manager is followed by their
whole team before the next manager at the same level. Each row also carries its chain of managers, one name longer
per step:
CREATE TABLE staff (id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager INTEGER REFERENCES staff (id));
INSERT INTO staff VALUES
(1, 'Leela', NULL), (2, 'Arjun', 1), (3, 'Divya', 1),
(4, 'Farhan', 2), (5, 'Gita', 2), (6, 'Harish', 3), (7, 'Isha', 5);
-- SQLite only: ORDER BY inside the recursive part chooses which queued row is
-- expanded next. Deepest first walks each branch to its end before the next.
WITH RECURSIVE tree(id, name, level, chain) AS (
SELECT id, name, 0, name FROM staff WHERE manager IS NULL
UNION ALL
SELECT s.id, s.name, tree.level + 1, tree.chain || ' > ' || s.name
FROM staff AS s JOIN tree ON s.manager = tree.id
ORDER BY 3 DESC
)
SELECT name, level, chain FROM tree; Output
┌────────┬───────┬─────────────────────────────┐ │ name │ level │ chain │ ├────────┼───────┼─────────────────────────────┤ │ Leela │ 0 │ Leela │ │ Arjun │ 1 │ Leela > Arjun │ │ Farhan │ 2 │ Leela > Arjun > Farhan │ │ Gita │ 2 │ Leela > Arjun > Gita │ │ Isha │ 3 │ Leela > Arjun > Gita > Isha │ │ Divya │ 1 │ Leela > Divya │ │ Harish │ 2 │ Leela > Divya > Harish │ └────────┴───────┴─────────────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < org_chart.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Arjun’s whole team, down to Isha, comes before Divya. In PostgreSQL, where the evaluation order is not yours to
choose, the same listing comes from SEARCH DEPTH FIRST BY id SET walk and ORDER BY walk in the outer query.
A bill of materials adds arithmetic along the way. One bicycle needs 2 wheels, each wheel needs 36 spokes, so the recursion multiplies the quantities down every path and the outer query adds up the paths per part:
-- A bill of materials: one row per "this assembly needs qty of that part".
CREATE TABLE parts_of (assembly TEXT, part TEXT, qty INTEGER, PRIMARY KEY (assembly, part));
INSERT INTO parts_of VALUES
('bicycle', 'frame', 1), ('bicycle', 'wheel', 2), ('bicycle', 'pedal', 2),
('wheel', 'rim', 1), ('wheel', 'spoke', 36), ('wheel', 'hub', 1),
('hub', 'bearing', 2), ('pedal', 'bearing', 2);
-- Multiply the quantities along each path down the tree, then add up per part.
WITH RECURSIVE needs(part, qty) AS (
SELECT part, qty FROM parts_of WHERE assembly = 'bicycle'
UNION ALL
SELECT p.part, needs.qty * p.qty
FROM parts_of AS p JOIN needs ON p.assembly = needs.part
)
SELECT part, sum(qty) AS total FROM needs GROUP BY part ORDER BY part;
-- The same query with UNION: two different paths each give ('bearing', 4),
-- and UNION keeps only one of the two equal rows.
WITH RECURSIVE needs(part, qty) AS (
SELECT part, qty FROM parts_of WHERE assembly = 'bicycle'
UNION
SELECT p.part, needs.qty * p.qty
FROM parts_of AS p JOIN needs ON p.assembly = needs.part
)
SELECT part, sum(qty) AS total FROM needs WHERE part = 'bearing' GROUP BY part; Output
┌─────────┬───────┐ │ part │ total │ ├─────────┼───────┤ │ bearing │ 8 │ │ frame │ 1 │ │ hub │ 2 │ │ pedal │ 2 │ │ rim │ 2 │ │ spoke │ 72 │ │ wheel │ 2 │ └─────────┴───────┘ ┌─────────┬───────┐ │ part │ total │ ├─────────┼───────┤ │ bearing │ 4 │ └─────────┴───────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < bom.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Bearings come from two places: 2 wheels with one hub each and 2 bearings per hub, and 2 pedals with 2 bearings each.
Both paths produce the row ('bearing', 4). UNION ALL keeps both and the total is 8; UNION treats the second as
a repeat, drops it, and silently reports 4. UNION is not a free cycle check: it also merges rows that are equal for a
good reason. Here the data is a tree, so UNION ALL is safe and correct.
Datalog: recursion as rules
Datalog writes a database as facts and a query as rules. A fact such as route("Chennai", "Bengaluru") is a
row. A rule says that its head holds whenever every part of its body does, with shared variables meaning a join.
Reachability is two rules:
reach(X, Y) :- route(X, Y).A direct train is a trip.reach(X, Y) :- reach(X, Z), route(Z, Y).A trip followed by one more train is a trip.
The second rule mentions reach on both sides: that is recursion. Its meaning is the smallest set of facts that
satisfies both rules, reached by applying the rules until nothing new appears. Naive evaluation joins every fact
known so far in every round. Semi-naive evaluation joins only the facts that were new in the round before,
because joining two old facts again can only rederive a fact already known. This program runs both on the lesson’s
routes and counts how often the second rule fires:
"""The Datalog program of the lesson, evaluated twice: naively and semi-naively.
reach(X, Y) :- route(X, Y).
reach(X, Y) :- reach(X, Z), route(Z, Y).
unreachable(Y) :- city(Y), not reach("Chennai", Y).
A naive round joins every reach fact known so far with route; a semi-naive round joins only the
facts that were new in the round before. Both stop when a round adds nothing new.
"""
ROUTES = {
("Chennai", "Bengaluru"), ("Bengaluru", "Mysuru"), ("Mysuru", "Chennai"),
("Bengaluru", "Hubballi"), ("Hubballi", "Pune"), ("Pune", "Mumbai"),
("Mumbai", "Pune"), ("Kolkata", "Bhubaneswar"),
}
CITIES = {c for pair in ROUTES for c in pair}
def evaluate(semi_naive):
reach = set(ROUTES) # first rule: every route is a reach fact
delta = set(ROUTES) # the facts that are new since the last round
rounds, joined = 0, 0
while delta:
rounds += 1
source = delta if semi_naive else reach
derived = set()
for x, z in source:
for z2, y in ROUTES:
if z == z2:
joined += 1 # one firing of the second rule
derived.add((x, y))
delta = derived - reach
reach |= delta
print(f" round {rounds}: {len(derived):2} derived, {len(delta):2} new, {len(reach)} facts")
return reach, rounds, joined
for name, semi in (("naive", False), ("semi-naive", True)):
print(f"{name}:")
reach, rounds, joined = evaluate(semi)
print(f" {rounds} rounds, the second rule fired {joined} times\n")
from_chennai = sorted(y for x, y in reach if x == "Chennai")
print("reach(Chennai, Y):", ", ".join(from_chennai))
# Negation is applied only once reach is complete: it sits in a higher stratum.
unreachable = sorted(CITIES - set(from_chennai))
print("unreachable(Y): ", ", ".join(unreachable)) Output
naive: round 1: 8 derived, 8 new, 16 facts round 2: 17 derived, 6 new, 22 facts round 3: 23 derived, 2 new, 24 facts round 4: 24 derived, 1 new, 25 facts round 5: 24 derived, 0 new, 25 facts 5 rounds, the second rule fired 102 times semi-naive: round 1: 8 derived, 8 new, 16 facts round 2: 9 derived, 6 new, 22 facts round 3: 7 derived, 2 new, 24 facts round 4: 2 derived, 1 new, 25 facts round 5: 1 derived, 0 new, 25 facts 5 rounds, the second rule fired 27 times reach(Chennai, Y): Bengaluru, Chennai, Hubballi, Mumbai, Mysuru, Pune unreachable(Y): Bhubaneswar, Kolkata
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 datalog.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both find the same 25 facts in 5 rounds, but the naive version fired the rule 102 times and the semi-naive one 27
times, because naive rounds 2 to 5 rederived everything found earlier. The working table of WITH RECURSIVE is the
semi-naive idea: each step joins only the rows of the step before.
The last line of the output uses negation: unreachable(Y) :- city(Y), not reach("Chennai", Y). It is safe only
once reach is complete, because a city that looks unreachable in round 2 may be reached in round 4. Datalog
therefore splits a program into strata: a predicate that is negated must be computed fully, in a lower stratum,
before any rule that negates it runs. A program whose negation runs through its own recursion, such as “a city is far
if it is not reached yet”, cannot be stratified, and it has no single sensible answer. The PostgreSQL error about
NOT EXISTS (… FROM far …) above enforces exactly this, and the ban on aggregates in the recursive member follows the
same reasoning. Ceri, Gottlob and Tanca’s survey sets out the evaluation methods and stratification in detail.
GATE CS
The GATE 2027 CS syllabus lists relational algebra, tuple calculus and SQL in its databases section. The limit described here connects them: algebra and safe tuple calculus can join only a fixed number of times, so a question such as “every city reachable by any number of trains” is out of their reach, while a fixed number of trains is not.
Exercise: the fewest trains from Delhi
Exercise · Medium · SQL
Every city reachable from Delhi, with the fewest trains
The table routes has one row for every direct train: src is the city it leaves from and dst the city it goes to. Trains run one way only, a pair of cities may be linked in both directions, and the network may contain cycles.
Write a query that returns every city that can be reached from Delhi, with two columns:
city: the city;trains: the smallest number of trains needed to get there from Delhi (1 for a direct train).
Leave Delhi itself out, even when some trip leads back to it, and leave out every city that cannot be reached. Sort the rows by trains, then by city in alphabetical order; the order is checked. The network never has more than 10 cities, so no useful trip takes more than 9 trains.
The starter query finds only the direct trains. Make it follow changes of train too, without looping forever on a cycle.
Starter code · fewest_trains.sql
-- Every city reachable from Delhi, with the fewest trains needed.
-- This version finds only the direct trains: make it follow changes too.
SELECT dst AS city, 1 AS trains
FROM routes
WHERE src = 'Delhi'
ORDER BY trains, city; The sample tests · tests.yaml
# Sample tests: each one builds a small routes table, runs your query and compares the rows it returns, in order.
tests:
- name: a tree of routes, two and three trains deep
ordered: true
setup: |
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Delhi', 'Agra'), ('Agra', 'Jhansi'), ('Delhi', 'Jaipur'),
('Jaipur', 'Ajmer'), ('Jhansi', 'Bhopal');
expected:
columns: [city, trains]
rows:
- ['Agra', 1]
- ['Jaipur', 1]
- ['Ajmer', 2]
- ['Jhansi', 2]
- ['Bhopal', 3]
- name: cycles back to Delhi and a direct train that beats a longer trip
ordered: true
setup: |
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Delhi', 'Agra'), ('Agra', 'Delhi'), ('Agra', 'Gwalior'),
('Gwalior', 'Jhansi'), ('Delhi', 'Jhansi'), ('Jhansi', 'Agra');
expected:
columns: [city, trains]
rows:
- ['Agra', 1]
- ['Jhansi', 1]
- ['Gwalior', 2]
- name: cities that Delhi cannot reach stay out
ordered: true
setup: |
CREATE TABLE routes (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst));
INSERT INTO routes VALUES
('Delhi', 'Chandigarh'), ('Kochi', 'Kollam'), ('Kollam', 'Delhi');
expected:
columns: [city, trains]
rows:
- ['Chandigarh', 1] A hint
Start a WITH RECURSIVE query from the direct trains out of Delhi, with 1 as the number of trains, and add one train in the recursive part by joining routes on src = the city reached so far. Because each row carries its train count, UNION alone does not stop a cycle: add WHERE trip.trains < 9. Afterwards keep the smallest count per city with min() and GROUP BY.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What is a recursive CTE, and what makes it stop? (warm-up)
A recursive common table expression, written WITH RECURSIVE, has an anchor query that produces the starting rows
and a recursive query, joined to it by UNION or UNION ALL, that refers to the expression itself and produces the
next rows from the rows of the previous step. The database runs the anchor once, then runs the recursive part again on
each step’s new rows, and stops when a step adds nothing. It stops on its own only if the rows eventually stop being
new: with UNION, repeated rows are dropped, so plain reachability over a cycle ends, but a counter or a path column
makes every row new. Then you need a guard such as depth < 10, a path check, or PostgreSQL’s CYCLE clause. Use
UNION ALL when equal rows from different paths must all count, as in a bill of materials.
Key takeaways
WITH RECURSIVEis an anchor plus a recursive member; it repeats the member on the previous step’s rows until a step adds nothing. Relational algebra cannot do this, because every expression joins a fixed number of times.UNIONstops cycles only while rows can repeat exactly; a counter or path column defeats it. Guards stop runaway queries but cost rows in proportion to the limit; path checks and PostgreSQL’sCYCLEclause detect the repeat.UNIONalso merges rows that are equal for a good reason, so totals that add up paths, as in a bill of materials, needUNION ALLon data without cycles.- PostgreSQL forbids aggregates and self-referencing subqueries in the recursive member, has
SEARCHandCYCLE, and evaluates only as many rows as the outer query reads. SQLite has neither clause but letsORDER BYsteer its queue. - In Datalog, recursion is a rule that mentions its own head. Semi-naive evaluation, joining only new facts, is what the working table does, and negation must wait for the stratum below it to finish.
References
- PostgreSQL 18: WITH Queries (Common Table Expressions) (The PostgreSQL Global Development Group)
- PostgreSQL 14 release notes (SEARCH and CYCLE clauses) (The PostgreSQL Global Development Group)
- SQLite: The WITH Clause (SQLite)
- Universality of data retrieval languages (Aho and Ullman, POPL 1979) (Association for Computing Machinery)
- What you always wanted to know about Datalog (and never dared to ask) (Ceri, Gottlob and Tanca) (IEEE Transactions on Knowledge and Data Engineering)
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress