02 · CTEs & Recursive Queries¶
A Common Table Expression (CTE) is a named, temporary result set defined with
a WITH clause and used just like a table for the rest of the query. CTEs
exist to make complex queries readable — instead of nesting subqueries three
levels deep, you name each step and read the query top to bottom. A
recursive CTE goes further: it can reference itself, which is the
standard SQL way to walk hierarchical data (org charts, category trees,
folder structures) of unknown depth.
Sample schema¶
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
manager_id INTEGER,
salary REAL NOT NULL,
FOREIGN KEY (manager_id) REFERENCES employees(id)
);
INSERT INTO employees (id, name, manager_id, salary) VALUES
(1, 'Grace (CEO)', NULL, 220000),
(2, 'Heidi (VP Eng)', 1, 180000),
(3, 'Ivan (VP Sales)', 1, 170000),
(4, 'Judy (Eng Mgr)', 2, 140000),
(5, 'Kevin (Engineer)', 4, 110000),
(6, 'Liam (Engineer)', 4, 105000),
(7, 'Mia (Sales Mgr)', 3, 130000),
(8, 'Noah (Sales Rep)', 7, 90000);
manager_id is a self-referencing foreign key — it points to another row
in the same table, which is exactly what makes this data hierarchical rather
than flat.
A basic CTE¶
WITH high_earners AS (
SELECT id, name, salary
FROM employees
WHERE salary > 120000
)
SELECT name, salary FROM high_earners ORDER BY salary DESC;
name salary
--------------- --------
Grace (CEO) 220000.0
Heidi (VP Eng) 180000.0
Ivan (VP Sales) 170000.0
Judy (Eng Mgr) 140000.0
Mia (Sales Mgr) 130000.0
This is equivalent to wrapping the same SELECT as a subquery in FROM (...)
AS high_earners, but the WITH form names it before the main query, so
you read the intent ("here's what a high earner is") before you read what's
done with it.
Multiple CTEs, chained together¶
CTEs can reference each other — this is where they really outperform nested subqueries, because each step gets its own name instead of another indentation level:
WITH dept_size AS (
SELECT manager_id, COUNT(*) AS num_reports
FROM employees
WHERE manager_id IS NOT NULL
GROUP BY manager_id
),
managers_with_size AS (
SELECT e.name AS manager_name, ds.num_reports
FROM dept_size ds
JOIN employees e ON e.id = ds.manager_id
)
SELECT * FROM managers_with_size ORDER BY num_reports DESC;
manager_name num_reports
--------------- -----------
Grace (CEO) 2
Judy (Eng Mgr) 2
Heidi (VP Eng) 1
Ivan (VP Sales) 1
Mia (Sales Mgr) 1
managers_with_size references dept_size by name, and both are just
SELECTs under the same WITH, separated by commas — no nesting required.
Recursive CTEs — walking up a hierarchy¶
A recursive CTE has two parts, joined by UNION ALL: an anchor query
(the starting row(s)) and a recursive query that references the CTE's
own name, repeatedly joining one more step outward until nothing new
matches. SQLite requires the RECURSIVE keyword explicitly:
WITH RECURSIVE chain_of_command(id, name, manager_id, depth) AS (
-- anchor: the starting employee
SELECT id, name, manager_id, 0
FROM employees
WHERE name = 'Kevin (Engineer)'
UNION ALL
-- recursive step: walk one level up to the manager
SELECT e.id, e.name, e.manager_id, cc.depth + 1
FROM employees e
JOIN chain_of_command cc ON e.id = cc.manager_id
)
SELECT * FROM chain_of_command ORDER BY depth;
id name manager_id depth
-- ---------------- ---------- -----
5 Kevin (Engineer) 4 0
4 Judy (Eng Mgr) 2 1
2 Heidi (VP Eng) 1 2
1 Grace (CEO) 3
Each pass through the recursive step takes the previous pass's result
(chain_of_command, referring to itself) and joins one more level up the
manager_id chain. The recursion terminates naturally: once it reaches
Grace, whose manager_id is NULL, the join finds no matching row and adds
nothing more, so the next iteration produces zero rows and recursion stops.
Recursive CTEs — walking down a hierarchy¶
The same technique works in reverse — start at a manager and walk down to every person under them, at any depth:
WITH RECURSIVE org_tree(id, name, manager_id, depth) AS (
SELECT id, name, manager_id, 0
FROM employees
WHERE name = 'Heidi (VP Eng)'
UNION ALL
SELECT e.id, e.name, e.manager_id, ot.depth + 1
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT id, name, depth FROM org_tree ORDER BY depth, id;
id name depth
-- ---------------- -----
2 Heidi (VP Eng) 0
4 Judy (Eng Mgr) 1
5 Kevin (Engineer) 2
6 Liam (Engineer) 2
The only difference from the "walk up" version is which side of the join
condition drives the recursion: e.manager_id = ot.id walks down to direct
reports of the previous level, instead of e.id = cc.manager_id walking
up to the previous level's manager.
Recursive CTEs without any table at all¶
A recursive CTE doesn't need to reference a real table — it can generate a sequence purely from constants, which is useful for date ranges, counters, or filling gaps in reporting data:
WITH RECURSIVE counter(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM counter WHERE n < 5
)
SELECT n FROM counter;
The infinite-loop trap¶
The recursive step's WHERE clause (or an equivalent stopping condition) is
what makes recursion terminate — leave it out or get the join condition
backwards, and the query can spin close to forever, generating rows until it
hits SQLite's built-in safety limit or exhausts memory:
-- DANGEROUS: no upper bound, would run until SQLite's internal limit kicks in
-- WITH RECURSIVE counter(n) AS (
-- SELECT 1
-- UNION ALL
-- SELECT n + 1 FROM counter
-- )
-- SELECT * FROM counter;
Two other common mistakes to watch for:
- Using
UNIONinstead ofUNION ALL.UNIONdeduplicates every row before continuing, which is slower and can be outright wrong for hierarchies where two different rows legitimately share the same values —UNION ALLis almost always what you want in a recursive CTE. - Cyclic data. If
manager_idever pointed back down the chain (a data bug, since a real org chart shouldn't cycle), a "walk up" recursive CTE would loop forever even with a correct join, because it would never run out of new matching rows. Recursive CTEs trust the data to be acyclic; they don't detect cycles for you.
Cheat sheet¶
| Concept | Detail |
|---|---|
WITH name AS (...) |
Defines a non-recursive CTE, usable once in the query that follows |
WITH RECURSIVE name(...) AS (...) |
Required keyword in SQLite for a self-referencing CTE |
| Anchor query | The first SELECT, before UNION ALL — the starting row(s) |
| Recursive query | The second SELECT, which references the CTE's own name |
| Termination | Recursion stops automatically once the recursive step returns zero new rows |
UNION ALL vs UNION |
Always use UNION ALL in recursion — UNION's deduplication is wasted work and can hide legitimate duplicate rows |
| Multiple CTEs | Separate with commas: WITH a AS (...), b AS (...) SELECT ... |
How It Actually Works¶
A non-recursive CTE (WITH x AS (...)) is handled much like a subquery: by
default SQLite treats it as inlineable and may fold it directly into the
outer query during planning (similar to view expansion) unless it's
referenced multiple times or marked MATERIALIZED, in which case it's
computed once into an ephemeral B-tree that later references reuse. A
recursive CTE works completely differently and mirrors exactly how the
SQL standard defines it operationally: the engine runs the "seed"
(non-recursive) branch once, inserting its rows into a working ephemeral
table; then it repeatedly re-runs the recursive branch only against the
newest batch of rows added in the previous iteration (not the whole
accumulated result), appending whatever it produces, until an iteration
produces zero new rows. This is literally a fixed-point loop implemented with
VDBE jump instructions back to the start of the recursive branch's
sub-program — which is exactly why an unbounded recursive CTE (say, a
cycle in a self-referential hierarchy with no termination condition) will
spin forever rather than error out; SQLite has no automatic cycle detection
unless you build it yourself with a "visited" tracking column.
Exercise¶
Using the employees schema above:
- Write a non-recursive CTE that finds the average salary per manager (via
GROUP BY manager_id), then a second CTE that joins it back toemployeesto show each manager's name alongside their team's average salary. - Write a recursive CTE that lists every employee under Ivan (VP Sales), at any depth, along with their depth in the hierarchy.
- Add a
SUMofsalaryto the "everyone under Heidi" query to find the total salary cost of Heidi's entire reporting chain, including Heidi herself. - Modify the number-sequence example to generate the numbers 10 through 20 instead of 1 through 5.