Recursion in SQL Sounds Strange — Until You See It
Most queries return rows from data that already exists. A recursive CTE is different: it builds rows by feeding its own output back as input, one step at a time, until there's nothing new to add. That's how you walk a tree of unknown depth, or generate the numbers 1 to 100 without a numbers table.
The shape is always the same:
WITH RECURSIVE name(columns) AS (
-- anchor: the starting rows
SELECT ...
UNION ALL
-- recursive: rows derived from the previous step
SELECT ... FROM name WHERE ...
)
SELECT * FROM name;
Anchor on top, UNION ALL, recursive query underneath. SQLite runs the anchor once, then keeps running the recursive part — each time using the rows produced last round — until it returns no new rows. Then it stops.
Counting From 1 to 10
The simplest recursive CTE generates a series. No tables needed:
Trace through it:
- Anchor produces one row:
n = 1. - Recursive step takes that row, computes
n + 1 = 2, and2 < 10is true, so it keeps the row. - Next iteration takes
n = 2and producesn = 3. And so on. - When
nreaches10,10 < 10is false, the recursive step returns no rows, and SQLite stops.
The WHERE n < 10 is the stopping condition. Without it, the query runs forever.
Generating a Date Series
Same idea, useful in real reports — fill in every day in a range, even days where nothing happened:
You'd typically LEFT JOIN this against an events table to count zero-event days correctly. A plain GROUP BY date skips empty days entirely; the date series gives you a row for every day, regardless.
Walking a Parent-Child Tree
The classic use case. Here's an employee table where each row points at its manager:
The anchor picks the root (the person with no manager). The recursive step joins the employees table back against the CTE, finding everyone whose manager_id matches an id already in the CTE. Each iteration goes one level deeper. depth is just a counter we add to indent the output.
This works for trees of any depth. Two levels, ten levels — the query doesn't change.
Finding All Ancestors of a Specific Row
Flip the direction. Instead of walking down from the root, walk up from a specific employee to find their entire chain of managers:
The anchor is the starting employee. Each recursive step jumps to the parent. SQLite stops when it hits the root — manager_id IS NULL, so the join finds nothing.
This pattern is useful for breadcrumbs, threaded comments, category paths, and anywhere you need "walk up to the top."
Stopping Conditions and Infinite Loops
The most common bug is forgetting the stopping condition or writing one that never fires. Compare:
-- Runs forever:
WITH RECURSIVE bad(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM bad
)
SELECT n FROM bad;
There's no WHERE clause to ever return zero rows. SQLite will happily try to count to infinity.
Two defensive habits:
- Always have a
WHEREclause in the recursive part that bounds growth. - Add
LIMITto the outerSELECTas a safety net while you're developing — if you're wrong about the stopping condition, the query still terminates.
The CTE itself is unbounded, but LIMIT 5 stops the outer query early. SQLite is smart enough not to keep recursing past what LIMIT needs. Useful for exploring; not a substitute for a real stopping condition in production code.
Cycles in Graphs
Trees can't have cycles. General graphs can — and a naive recursive CTE will loop forever if the data has one. The fix is to track the path you've walked so far and refuse to revisit nodes:
path is a comma-delimited string of nodes already visited. Before adding a new node, the WHERE clause checks it isn't in there. Without that guard, the cycle 1 → 2 → 3 → 1 would loop forever.
There's no built-in "visited set" in SQL — you build one yourself, usually as a string or by joining against the CTE so far.
Recursive CTE vs Self Join
If you only need one or two levels deep, a self join is simpler and faster:
That handles "who is each person's direct manager." But if you need "everyone who reports up through Ada, no matter how deep" — the depth is unknown — only a recursive CTE handles it cleanly. Pick the tool to match the depth you need:
- Fixed, small depth: self join, maybe two or three of them.
- Unknown or arbitrary depth:
WITH RECURSIVE.
Mental Model
A recursive CTE is a loop, written declaratively:
- The anchor is the loop's initial value.
- The recursive query is the loop body — it produces the next batch of rows from the current ones.
- The stopping condition is the loop's exit test — when it returns zero rows, the loop ends.
UNION ALLaccumulates everything into the final result set.
Once that maps in your head, the syntax stops feeling weird. You're writing a for loop in SQL.
Next: Indexes
Recursive CTEs walk a lot of rows, and the join inside the recursive step runs on every iteration. If the join column isn't indexed, performance falls off a cliff fast. Indexes are the next chapter, and manager_id is exactly the kind of column that benefits from one.
Frequently Asked Questions
What is a recursive CTE in SQLite?
A recursive CTE is a WITH RECURSIVE query that builds a result set by repeatedly referring to itself. It has two parts joined by UNION ALL: an anchor query that produces the starting rows, and a recursive query that produces more rows from the previous step. SQLite keeps running the recursive part until it returns no new rows.
When should I use WITH RECURSIVE in SQLite?
Reach for it when you need to walk a tree or graph (employees and managers, categories and subcategories, threaded comments) or generate a sequence (every date in a range, numbers 1 to 100). Plain joins handle one or two levels deep; a recursive CTE handles arbitrary depth without knowing it in advance.
How do I avoid infinite loops in a SQLite recursive CTE?
Make sure the recursive query has a stopping condition — a WHERE clause that eventually returns zero rows, or a counter that you cap. For graphs with cycles, track the visited path in a column and exclude rows already in it. As a safety net, add LIMIT to the outer query so a runaway recursion can't fill memory.