skip to content

Why does a StopIteration raised inside a generator body surface as RuntimeError?

level: seniorimportance: should knowfreq 30%

answer

  1. A generator that ended too early, silently
  2. An exception leaking out past the yields
  3. PEP 479 changed what may escape
  4. 3.7 made the swap the default
  5. The original is kept as __cause__

basics

~20 s

PEP 479 made it an error for StopIteration to escape a generator frame, because it used to end the generator silently and truncate output. Since Python 3.7 the escaping exception is replaced by RuntimeError, chained to the original.

solid answer

~50 s

`StopIteration` is the signal that an iterator is finished, and a generator object emits it at its own boundary when the function returns. Before PEP 479, a `StopIteration` raised *inside* the body - typically by a bare `next()` on some inner iterator that ran dry - was indistinguishable from that signal, so the generator simply appeared to end. Consumers saw a short result and no error: silent data loss. PEP 479 shipped as `from __future__ import generator_stop` in 3.5 and became the default in 3.7; today CPython replaces any `StopIteration` escaping a generator frame with `RuntimeError("generator raised StopIteration")`, keeping the original as `__cause__`. The fix in your own code is to stop letting it escape: give the inner `next()` a default and `return` when the sentinel comes back, or wrap it in `try/except StopIteration` and return there.

code

python · 10 lines
python
def records(lines):
    it = iter(lines)
    for header in it:
        body = next(it)
        yield header, body

try:
    list(records(["2026-09-04 alice:"]))
except RuntimeError as exc:
    print(exc, "| cause:", type(exc.__cause__).__name__)

go deeper

for a junior

Know that StopIteration means an iterator is finished, and that seeing RuntimeError with the message about a generator raising StopIteration points at a next() call inside a generator body.

for a middle

Explain the mechanics: the interpreter replaces an escaping StopIteration with RuntimeError and keeps the original as cause. Name PEP 479, the 3.5 opt-in and the 3.7 default, and show the sentinel fix.

for a senior

Demonstrate you have debugged the silent-truncation class of bug: how it hides in a data pipeline, why a broad except RuntimeError re-hides it, and that hand-written next implementations are still unprotected.

for a principal

Own the reasoning behind a deliberate breaking change - a data-loss failure mode traded against compatibility, staged through a future import - and the review standards that keep exception signals from crossing module boundaries.

`StopIteration` occupies an unusual position in Python: it is an exception used as a control-flow signal. An iterator raises it to say "finished", a `for` loop catches it and exits, and a generator object raises it at its boundary when its function body returns. Because that same exception type means "I am done" at the boundary, an accidental `StopIteration` from *inside* the body used to be read as the same message. ### The bug PEP 479 was written to kill Consider a chat-transcript archiver whose generator reads a header line and then the body line that follows it: ```python def records(lines): it = iter(lines) for header in it: body = next(it) # raises StopIteration on a truncated file yield header, body ``` On a well-formed file this works. On a file truncated mid-record - a crash during a write, a partial upload, a locale-dependent format that split a record across a boundary - the inner `next()` finds nothing. Before PEP 479, that `StopIteration` propagated out of the generator frame, the consumer's `for` loop interpreted it as the normal end signal, and the loop finished quietly. The caller got fewer records than the file contained, with no traceback, no log line, and no exit code. Silent truncation is the worst failure mode there is: the pipeline downstream sees a plausible, smaller dataset. The same trap appeared wherever a helper that could raise `StopIteration` was called inside a generator - a shared parsing function, a `__next__` implementation reused across modules, a callback whose contract nobody had read. ### What Python does now Since the change became default behaviour in Python 3.7 (it was available from 3.5 via `from __future__ import generator_stop`), a `StopIteration` that escapes a generator frame is caught by the interpreter and replaced with `RuntimeError`, whose message is `generator raised StopIteration`. The original exception is attached as `__cause__`, so the traceback shows both the `RuntimeError` at the generator boundary and the `StopIteration` beneath it, at the line that actually raised. Silent truncation became a loud, located failure. On 3.14 this is simply how generators behave; there is no way back and no flag to restore the old semantics. Asynchronous generators carry the equivalent guard for their own end-of-iteration exception. ### How to write code that never hits it The two-argument `next()` is the tidy fix, because it converts exhaustion into a value the generator can act on: ```python _END = object() def records(lines): it = iter(lines) for header in it: body = next(it, _END) if body is _END: return # a clean end of the generator yield header, body ``` `return` inside a generator is the *correct* way to finish: it produces the boundary `StopIteration` through the proper channel, so no `RuntimeError` is involved. A `try/except StopIteration:` around the inner call that then returns (or logs and returns) is equally valid, and is the better shape when you want to record that the input was truncated rather than accept it as normal. What you must not do is let the exception travel out of the body. ### The limits of the protection PEP 479 guards **generator frames**. A hand-written iterator class whose `__next__` internally calls something that raises `StopIteration` still has the old failure mode: the exception is exactly the signal `__next__` is supposed to give, so the consuming loop ends early and quietly. If you write `__next__` by hand, guard the inner calls yourself. Second, `RuntimeError` is a broad type. Code that wraps a pipeline in `except RuntimeError:` and continues will re-hide the very failure PEP 479 exposed. If you catch it, catch it narrowly and inspect `__cause__` before deciding it is benign. Third, this is a *runtime* change, not a static one: nothing warns you at import time that a helper you call can raise `StopIteration`. Tests that only feed well-formed input never reach the path, which is why the truncated-input case belongs in the test suite explicitly. ### Why it is a senior question It requires knowing that `StopIteration` is a signal rather than an error, recognising the silent-truncation class of bug, and knowing the version at which the semantics changed. The candidate who has debugged a pipeline that quietly produced 80% of its expected rows answers it from memory.

  • What problem did the old behaviour cause that justified a breaking change?
    Silent truncation. A `StopIteration` from an inner call escaped the generator and was read by the consumer's loop as the normal end signal, so a pipeline processed part of its input and reported success. There was no traceback and no log line, and the shortfall usually surfaced days later as missing rows downstream. Converting it to `RuntimeError` turns a data-loss bug into an immediate, located failure - worth the compatibility break.
  • Does PEP 479 protect a hand-written iterator class in the same way?
    No. The conversion applies to generator frames only. If a class's `__next__` calls something that raises `StopIteration`, that exception is exactly the signal `__next__` is contracted to raise, so the consuming loop still ends early and quietly. When you implement `__next__` by hand, guard inner calls yourself - a default on `next()`, or an explicit `try/except StopIteration` that decides deliberately whether this really is the end.
  • How would you finish a generator early without triggering the RuntimeError?
    Use a plain `return` in the body. That is the sanctioned way for a generator to signal completion: the interpreter raises the boundary `StopIteration` on its behalf and consumers see a normal end of iteration. Combine it with a two-argument `next()` so exhaustion arrives as a sentinel value you can test, or with `try/except StopIteration` when you want to log the truncated input before returning.

The old behaviour was a page torn out of a transcript with the remaining pages renumbered - nothing looked wrong. PEP 479 staples a loud error where the tear is.

saying these in an interview costs you the question

  • Thinks the escaping StopIteration still just ends the generator
  • Cannot name a version and says it always behaved this way
  • Believes RuntimeError means a bug in the interpreter
  • Suggests catching RuntimeError broadly and continuing
  • Assumes a hand-written __next__ gets the same protection
  • Confuses return in a generator with letting StopIteration escape

context