A recursive CTE never finishes and has to be killed — how do you make it terminate?
answer
- Ask what makes a pass empty
- The stop predicate must live in the recursive member
- Outer clauses run after the loop has finished
- Carry a number that grows every pass
- Bound it so the data cannot decide
basics
~20 sRecursion ends only when a pass produces no rows, so an unbounded query means the recursive member always finds something. The reliable fix is a depth counter incremented each pass and filtered inside the recursive member, which bounds work regardless of the data.
solid answer
~60 sStart from the only stop condition there is: iteration ends when a pass of the recursive member returns no rows (or, with `UNION`, no rows that are not already in the result). A query that never finishes therefore has a recursive member that always matches something — data that leads back to rows already visited, a join predicate that keeps re-matching, or a counter that never crosses its bound. The dependable fix is a **depth counter**: select a constant in the anchor, `depth + 1` in the recursive member, and add `WHERE depth < 100` to the recursive member so the loop is bounded by construction rather than by hoping the data behaves. Switching `UNION ALL` to `UNION` helps when repeated rows are the cause, but only while no extra column makes those rows distinct. Do not rely on an outer `LIMIT` or `WHERE` to stop the iteration, and do not rely on the engine to stop it for you: engines differ on whether they cap depth or simply run until memory or disk is exhausted.
code
sql · 7 lines-- Unbounded: every pass has an input row and produces one
WITH RECURSIVE t(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM t -- no stop predicate
)
SELECT n FROM t;go deeper
Remember that the stop condition is a WHERE clause inside the recursive member, and that a recursive CTE without one runs until something kills it.
Explain the mechanism: iteration ends only on an empty pass, so name the concrete reasons a pass always finds rows and show the depth-counter pattern that bounds it.
Show the diagnosis: reproduce with a small bound, count rows per depth to separate deep data from fan-out, check the anchor and the join correlation, then ship the bounded version.
Own the standard — every recursive CTE over data you do not control ships with a depth bound and the depth column exposed, so a bad data day degrades into a finite wrong answer rather than an outage.
## Why it runs forever A recursive CTE stops when a pass produces nothing. Everything else is a consequence. So a runaway query has exactly one cause in the abstract — every pass finds at least one row — and a handful of concrete forms: - **A missing or wrong stop predicate.** `SELECT n + 1 FROM t` with no `WHERE` is the textbook case: every pass has an input row and produces an output row, forever. - **A predicate that can never be false.** `WHERE n <> 0` on a counter that never reaches 0, or a bound compared against a column the recursive member never changes. - **A join condition that keeps re-matching.** If the recursive member joins on a relationship the data allows to revisit rows already produced, each pass keeps finding successors and the frontier never empties. - **Fan-out without repetition.** Even acyclic data can blow up: if each row expands into several, the frontier grows geometrically and the query is effectively unbounded in practice even though it would technically terminate. ## The one guard that always works Carry a depth counter and bound it inside the recursive member: ```sql WITH RECURSIVE walk(node, depth) AS ( SELECT 'a', 0 -- anchor seeds the counter UNION ALL SELECT e.dst, w.depth + 1 FROM walk w JOIN edges e ON e.src = w.node WHERE w.depth < 50 -- hard stop, independent of the data ) SELECT node, depth FROM walk; ``` Why this is the answer to give: it does not depend on any assumption about the shape of the data. Whatever the rows look like, after 50 passes the predicate is false for every row in the working set, the pass is empty, and the query ends. Choose the bound from the domain — a category tree that is five levels deep in production gets a bound of, say, 50, generous enough never to fire in normal operation and small enough to convert a data problem into a fast, finite answer instead of an outage. Selecting the depth column also makes the failure legible afterwards: if rows come back at the bound, you know you hit the guard rather than the natural end of the data. ## What does not work **An outer `LIMIT`.** The CTE is defined independently of the query reading it. Whether an engine can pipeline a `LIMIT` above a recursive CTE and stop the iteration early depends on the engine and on the plan it chose; some can, and the same query with a slightly different shape may not. It is not a stop condition — it is a coincidence you do not control. **An outer `WHERE`.** Unambiguously too late. It filters a result the engine must finish computing first. **Trusting the engine.** Some engines impose a default recursion-depth limit and raise an error when it is exceeded; others will keep going until they exhaust memory or spill to disk and fill the volume. Both are bad outcomes, and which one you get is a vendor detail. Write the bound yourself. **`ORDER BY` or `DISTINCT` in the outer query.** Neither participates in the iteration at all. ## What UNION buys, and its limit Replacing `UNION ALL` with `UNION` discards rows already produced before they are appended, so they never seed another pass; when the runaway is caused by revisiting the same rows, that alone converges. The limit is that duplicate elimination compares the entire row, so a depth counter, an accumulated path or any per-branch value makes each revisit a distinct row and the safety net vanishes. Since you usually want that depth column anyway, treat `UNION` as an optimisation and the depth bound as the guard. (Detecting an actual repetition in the data, as opposed to simply bounding the work, is a separate technique with its own trade-offs.) ## Diagnosing one in production 1. Reproduce with a small bound — set `WHERE depth < 5` and look at what comes back. If rows are still arriving at the bound in data you expected to be shallow, the data, not the query, is the problem. 2. Add the depth column to the output and group by it. A count per depth that stays flat or grows says the frontier is not shrinking; a count that grows by a constant factor per level says fan-out, not repetition. 3. Check the anchor. An anchor that returns far more rows than you intended (a missing filter on the seed) multiplies everything downstream. 4. Check the join predicate for a missing correlation — a recursive member that joins on a condition that is effectively always true re-expands the whole table every pass. ## The template to remember Every recursive CTE that touches data you do not fully control should ship with: a depth column, a `WHERE depth < n` in the recursive member, and the depth column exposed in the outer select. That is cheap, portable, and turns an unbounded query into a bounded one whose output tells you whether the bound fired.
- Why is a LIMIT on the outer query not a reliable stop condition?The CTE is defined independently of the statement that reads it, so the outer `LIMIT` applies to a result the engine is producing. Whether it can pipeline that limit down and cut the iteration short depends on the engine and the plan chosen; the same query can stop today and hang after a plan change. Put the bound in the recursive member.
- How do you choose the depth bound?From the domain, generously. Pick a value well above the deepest legitimate case — a five-level category tree gets 50, not 6 — so the guard never fires in normal operation but converts pathological data into a fast, finite answer. Select the depth column too, so you can tell from the output whether the guard fired.
- You bound the depth and the query still takes forever. What else could be wrong?Fan-out rather than depth. If each row expands into many, the frontier grows geometrically and a depth of 20 can still mean an enormous row count. Group the result by depth to see the row count per level, check the anchor for a missing filter that seeds too many rows, and check the recursive member's join for a missing correlation.
A depth counter is a fuel tank rather than a map: you may not know where the road goes, but you know the car stops after fifty miles.
saying these in an interview costs you the question
- Adds LIMIT to the outer query and calls it fixed
- Believes the database always stops runaway recursion by itself
- Thinks a WHERE in the outer SELECT bounds the iteration
- Says recursive CTEs are just slow, not unbounded
- Blames the optimizer instead of the missing stop predicate