skip to content

A nightly subscription-billing run dies with RecursionError — how do you tell runaway recursion from legitimately deep data?

level: seniorimportance: should knowfreq 38%

answer

  1. Two causes, opposite fixes
  2. The traceback shape tells you which
  3. Repeated-frame marker, not the limit
  4. Reproduce one record, lower the limit
  5. A visited set settles the cycle question

basics

~20 s

Read the traceback's repeated-frame marker: a short cycle of one or two frames repeating means a runaway loop, while a long chain of different frames means deep data. Confirm by re-running the failing record with a lowered limit and a depth counter.

solid answer

~50 s

Start with the traceback, not the limit. `RecursionError` tracebacks collapse repeats into a `[Previous line repeated N more times]` marker: a tight cycle of one or two distinct frames is a runaway — a missing base case, a self-referential structure, or a `__getattr__` that reads a missing attribute on `self`. A long chain of *different* frames is genuinely deep input. Next, reproduce on the one failing record rather than the whole run: catch `RecursionError`, log the identifier of the entity being processed, and thread an explicit depth counter through the walk so you can print the depth reached against the size of the data. **Lowering** `sys.setrecursionlimit` in that reproduction makes it fail in seconds instead of after a long climb. Only after the two cases are distinguished does the fix follow: a cycle is a data or logic defect, while genuine depth is a capacity decision.

code

python · 12 lines
python
import sys
import traceback

sys.setrecursionlimit(200)

def supersedes(amendment):
    return supersedes(amendment)

try:
    supersedes("amendment-1")
except RecursionError:
    traceback.print_exc(limit=3)

go deeper

for a junior

Know that RecursionError has two very different causes — a loop with no exit, or data that is genuinely deep — and that the traceback's repeated-frame line is the first thing to read.

for a middle

Explain the reproduction technique: isolate the failing record, lower the limit so it fails fast with a small traceback, thread a depth counter and a visited set through the walk to prove which cause applies.

for a senior

Demonstrate production judgement: capture the failing entity's identifier at the boundary, recognise the self-referential-repr trap in the error path, and refuse to 'fix' a cycle by raising an interpreter-wide setting.

for a principal

Own the prevention: deterministic ordering instead of wall-clock comparison, a write-time constraint against cyclic references, and a documented, tested depth bound for every traversal over machine-generated data.

## Why the distinction is the whole diagnosis A `RecursionError` has exactly two causes, and they have opposite fixes. **Runaway recursion** — a cycle in the data or a base case that never matches — will exhaust any limit you give it, so raising the ceiling converts a fast failure into a slow, expensive one. **Legitimately deep data** — a chain that really is thousands of links long — is a capacity question, and the answer may be to allow more depth or to stop using the call stack to hold it. Deciding which one you have is the first, and often the only, real step. ## Read the traceback first CPython's traceback machinery collapses repeated frames, so the shape of the output is diagnostic: * **One or two distinct frames, then `[Previous line repeated 996 more times]`.** That is a self-sustaining cycle. Nearly always: a missing or unreachable base case, a structure that contains itself, mutual recursion between two functions with no terminating branch, or an attribute-lookup loop. * **A long, varied chain of frames.** Real depth. The call chain shows progress through genuinely different work at each level. Watch for the recursion that is not written as recursion at all. A `__getattr__` that reads `self.<something missing>` re-enters itself on every lookup; a `__repr__` that formats a self-referential object recurses through formatting — often while the logger is trying to render the very object involved in the original failure, which is how a small bug turns into a confusing one. ## Reproduce on one record, not the whole run A nightly batch is the worst place to debug. Capture the identity of the failing unit at the boundary: ```python import sys def process(account, walk): try: return walk(account) except RecursionError: print("recursion blew up on", account.id, "limit", sys.getrecursionlimit()) raise ``` Then reproduce that one record in isolation with the limit **lowered** — a value of a few hundred makes a runaway fail almost instantly and, importantly, keeps the traceback small enough to read. Thread an explicit depth parameter through the walk and record the maximum reached; comparing that number with the size of the record separates "1000 levels for 1000 links" from "1000 levels over a five-element structure". A cycle check settles it definitively: carry a set of already-visited identifiers through the walk and assert that the current node is not in it. If the assertion fires, you have a cycle in the data and the traversal was never going to terminate. ## The clock-skew shape A characteristic real cause in billing work: plan amendments that record which earlier amendment they supersede, ordered by an effective timestamp written by whichever host handled the change. When two hosts' clocks disagree, two amendments can each end up recorded as superseding the other. Nothing in the row-level data looks wrong; each record is individually valid. But the "walk back to the original plan" traversal now has a two-node cycle, and it recurses until the interpreter stops it. The tell is exactly the traceback shape above — two frames, repeated — plus a memory curve that climbs to a **2.4 GB working set** before the error, because every live frame retains its locals and the amendment objects they reference. Structural corruption from a timing defect looks like a code bug in the traceback, which is why the cycle probe matters more than re-reading the traversal. ## What memory tells you RSS climbing steeply just before the failure is normal for deep recursion and says little on its own — thousands of frames each holding locals add up quickly, even when the underlying data is small. What is informative is the *ratio*: a large working set against a small input is a cycle re-holding the same few objects through thousands of frames; a large working set against a genuinely large input is depth. Note that in a batch the memory may never be released promptly even after the exception, since the frames are only freed as the stack unwinds. ## Do not fix it by raising the limit Raising `sys.setrecursionlimit` before diagnosing is the standard mistake. For a cycle it buys nothing but a longer, more memory-hungry failure, and it can turn a catchable exception into a process death if the recursion passes through C code. It also hides the defect: the run may then succeed on most nights and fail on the ones where the skewed records appear, which is a far harder bug to schedule work against. ## Dispositions once you know * **Cycle:** fix it where it is created — a deterministic ordering key rather than a wall-clock comparison, plus a database-level guard against a two-way supersedes relation. Make the traversal defensive as well: carry the visited set in production, and fail with the offending identifiers rather than with a stack error. * **Genuine depth:** decide deliberately. Cap the depth at a documented bound and reject beyond it, give the work a thread with a larger stack, or move the pending work off the call stack. Whatever you choose, record the expected maximum depth as a tested property of the ingestion path so the next growth in the data is caught by a test rather than at 3 a.m. * **Either way:** log the entity identifier at the point of failure. A `RecursionError` without the record that caused it is nearly undebuggable in a batch that processes millions of them.

  • The traceback shows a repeated __repr__ frame rather than the traversal. What happened?
    The traversal probably failed first, and the error path then tried to render an object whose `__repr__` walks the same self-referential structure — commonly inside a log call. The visible recursion is in the reporting, not the cause. Reproduce with logging that formats only identifiers, or catch and log `type(obj).__name__` plus a primary key rather than the object itself, and the original failure reappears.
  • Would lowering the recursion limit in the batch job be a reasonable production change?
    As a deliberate guard, sometimes: a lower ceiling makes a runaway fail fast and cheaply instead of after a long memory climb. But it is interpreter-wide, so it constrains every other component in the process, and it fires at an arbitrary place. An explicit depth bound inside the traversal, raising a domain error naming the record, gives the same fast failure with far better diagnostics and no global side effect.
  • How do you keep this from recurring once the cyclic records are repaired?
    Fix the ordering to something deterministic rather than wall-clock comparison, add a constraint or validation that rejects a two-way supersedes relation at write time, keep the visited set in the production traversal so a cycle surfaces as a named data error, and add a test that asserts the maximum observed depth for a realistic dataset stays inside a documented bound.

Same alarm, two fires: either the corridor is genuinely a thousand doors long, or two doors open into each other. You do not solve the second by walking further.

saying these in an interview costs you the question

  • Raises the recursion limit before diagnosing the cause
  • Ignores the repeated-frame marker in the traceback
  • Assumes RecursionError always means a coding bug, never bad data
  • Cannot say which record caused the failure
  • Treats climbing memory alone as proof of deep data
  • Debugs by re-running the whole nightly batch each time

context