skip to content

What breaks in a recursive `yield from` flattener on strings or deeply nested data?

level: seniorimportance: should knowfreq 38%

answer

  1. Which leaf type is also a container?
  2. Iterate a one-character string, get what?
  3. Nesting depth is decided by the input
  4. Frames are not free per level
  5. Nothing fails until something consumes it

basics

~20 s

Strings are the trap: iterating a one-character string yields that same string, so a flattener recursing into anything iterable never terminates and raises RecursionError. Deep nesting also hits the recursion limit, and per-item cost grows with delegation depth.

solid answer

~50 s

The idiom is four lines — walk the node, `yield from` yourself for a nested child, `yield` a leaf — and it fails in three distinct ways. First, `str` and `bytes` are iterable, and iterating a one-character `str` produces that same one-character `str`, so 'recurse into anything iterable' is infinite descent and raises `RecursionError`; special-case them (and any other value you consider atomic, such as a `dict`, which iterates as bare keys) before the iterable test. Second, each nesting level is a real Python frame, so data nested deeper than the recursion limit — 1000 by default — raises `RecursionError` while the chain is being built; genuinely unbounded depth wants an explicit stack and a `while` loop. Third, `yield from` does not collapse the chain: every level is resumed for every item, so per-item cost grows linearly with depth. And because generators are lazy, none of it fails until something consumes the result.

code

python · 13 lines
python
def flatten(node):
    if isinstance(node, (str, bytes)):
        yield node
        return
    try:
        children = iter(node)
    except TypeError:
        yield node
        return
    for child in children:
        yield from flatten(child)

print(list(flatten([1, ["ab", [2, (3, 4)]], 5])))

go deeper

for a junior

Remember the one fact that catches everybody: strings are iterable, so a flattener that recurses into anything iterable will recurse into "a" forever. Check for str before you check for iterability.

for a middle

Explain all three limits — the string base case, the default recursion limit of 1000 frames, and the fact that a generator does nothing until consumed — and write the string-safe version correctly, including why iter() in a try/except TypeError is the duck-typed test.

for a senior

Show that you treat nesting depth as untrusted input: know when to abandon recursion for an explicit stack, why raising the recursion limit is not a fix, and that per-item cost grows with delegation depth. Point out that tests must consume the generator or they assert nothing.

for a principal

Own the call on what a flattener is allowed to assume about its data. Decide whether container-ness is declared by an explicit type list or duck-typed, what depth the system will accept before rejecting input, and whether a streaming interface is even the right contract for a stage downstream consumers must fully drain.

## The idiom, and why it is tempting Recursive flattening is the canonical showcase for `yield from`: a generator that walks a nested structure and, for each child that is itself nested, delegates to a fresh call of itself. It is short, it streams (nothing is materialised), and it reads exactly like the shape of the data. In a payroll CSV import that groups rows by department, then by cost centre, then by employee, a flattener like this is the natural way to turn the nested groups back into a flat row stream for validation. It is also where three separate Python-specific traps meet. ## Trap one: strings iterate forever The test 'is this child nested?' is usually written as 'is this child iterable?', either with `iter()` in a `try`/`except TypeError` or against `collections.abc.Iterable`. Both say yes to `str`. Now follow a one-character string through the recursion: iterating `"a"` produces `"a"`. The recursive call receives the identical object, asks the same question, gets the same answer, and descends again. There is no base case, and the run ends with `RecursionError: maximum recursion depth exceeded`. A multi-character string merely takes one extra level to get there. The fix is a whitelist of atomic types checked *before* the iterable test — `str` and `bytes` at minimum. The same argument applies to any type that is technically iterable but semantically a leaf in your data: iterating a `dict` yields its keys and silently drops every value, which does not raise anything at all and is therefore the worse bug. A flattener should decide what counts as a container by naming those types explicitly, not by asking whether iteration happens to work. ## Trap two: recursion depth is a hard limit Every level of nesting is a real Python frame, created while the delegation chain is built on the first advance. CPython's default recursion limit is 1000 (`sys.getrecursionlimit()`), so structures nested deeper than roughly that raise `RecursionError` — and for a flattener the depth is a property of the *input data*, not of your code, so hostile or merely surprising input decides whether you crash. `sys.setrecursionlimit()` raises the ceiling, but the C stack is the real constraint and pushing it far enough can end the process outright rather than raising, so it is not a fix for unbounded input. Where depth is genuinely unbounded, convert to iteration: push the top-level iterator on an explicit list used as a stack, loop while the stack is non-empty, push a child's iterator when you meet a container, and yield leaves. It is less elegant, it is depth-limited only by memory, and it is what you want when the nesting comes from data you do not control. ## Trap three: delegation depth is not free A popular half-truth is that `yield from` 'short-circuits' the chain, so a deep pipeline costs no more than a shallow one. It does not. Delegation is faster than an equivalent re-yielding loop, because resuming a level is handled inside the interpreter rather than by loop bytecode in the delegating frame, but every level in the chain is still resumed for every item that passes through it. Per-item cost grows roughly linearly with depth: streaming a fixed number of items through a fifty-level chain costs several times what the same items cost through a five-level chain. For flattening real data, where the depth is small and constant, this is irrelevant. For a recursive descent over deeply nested data with millions of leaves, it is the difference between a job that finishes and one that does not. ## The laziness trap on top All three failures are deferred. Calling the flattener runs none of its body — it returns a generator object — so a regression pack of a few hundred cases that merely calls it and asserts no exception passes every case vacuously, including the ones that would recurse forever. Any test of a generator must consume it, and the same applies to your validation stage: if the import pipeline only builds the generator and hands it on, the `RecursionError` surfaces in whatever code eventually iterates, far from the flattener. ## The anti-pattern worth naming The recursive-with-accumulator version is what people write instead, and its most common form carries its own Python-specific bug: a default argument holding a list is evaluated once, when the function is defined, and is therefore shared by every call that does not pass one. The second import run appends to the same list and returns the first run's rows as well. Generators sidestep this entirely — each call gets a fresh frame and nothing is shared — which is a real argument for the `yield from` version once you have handled the string and depth traps.

  • How would you rewrite the flattener so arbitrary nesting depth cannot raise RecursionError?
    Replace recursion with an explicit stack: push `iter(root)` onto a list, then loop while it is non-empty, taking the top iterator and advancing it. A leaf is yielded; a container has its `iter()` pushed on top so it is drained first. Depth then costs heap memory instead of Python frames, and the traversal order is unchanged. It also removes the per-level delegation cost, since there is no chain to resume.
  • Why is a dict a more dangerous leaf type than a string in a flattener?
    Because it fails silently. A string recurses until `RecursionError` tells you loudly that something is wrong; a `dict` iterates cleanly to its keys, so the flattener yields the keys, drops every value, and produces plausible-looking output. Decide which types are containers by naming them explicitly rather than by testing whether iteration succeeds.
  • Does `yield from` collapse a deep delegation chain into a single resume?
    No. It avoids the Python-level loop bytecode that a re-yielding `for` would execute at each level, which makes it measurably faster, but every level in the chain is still resumed for every item. Per-item cost grows roughly linearly with the depth of the chain, which is invisible at depth three and very visible at depth fifty over a large stream.

saying these in an interview costs you the question

  • Tests only whether a child is iterable, so strings recurse
  • Claims yield from short-circuits the whole delegation chain
  • Raises the recursion limit to handle unbounded nesting
  • Assumes recursion depth is bounded by the code, not the data
  • Tests the flattener without consuming the generator
  • Uses a list default argument as a shared accumulator

context