StopIteration in Python: Iterators Explained
StopIteration ends iterators when data runs out.
20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.
- ✓Writing for-loops and while-loops over lists in Python
- ✓Calling functions and reading exception tracebacks
- ✓Basic idea of generators as functions with yield
- StopIteration is the normal 'no more items' signal an iterator raises when exhausted — for-loops catch it silently, but manual next() lets it bubble up and crash.
- Fix it now: pass a sentinel like
next(it, None)so exhaustion returns a default instead of raising past your code. - You'll avoid the nasty variant where StopIteration inside a generator becomes RuntimeError — that's PEP 479, and it means a helper raised StopIteration where it should have returned.
- Remember for-loops swallow StopIteration by design, so a stray StopIteration from a buggy helper can silently truncate your loop with no error at all.
Picture a ticket roll at a deli counter. Each next() call tears off one numbered ticket. When the roll is empty, the machine flashes 'no more tickets' — that's StopIteration. A for-loop is a polite customer who sees the flash and walks away. Manual next() is yanking the lever yourself. And PEP 479 says kitchen staff can't shout 'no more tickets' — escapes become RuntimeError so the bug gets found.
Your loop processes 9,800 rows then dies with a bare StopIteration and no message. Or worse — it processes 9,800 of 10,000 rows, exits cleanly, and nobody notices 200 rows vanished. Both are the same signal handled two ways: an iterator ran dry, and the code around it either caught the signal correctly or let a stray one masquerade as the end of data.
StopIteration is Python's iterator exhaustion protocol. Every for-loop depends on it: the loop calls next() repeatedly and stops when StopIteration arrives. That machinery is invisible until you call next() manually, write a generator, or raise StopIteration yourself inside generator code — three situations where the signal escapes its normal channel and becomes either a crash or silent truncation.
The sharpest edge is PEP 479. Since Python 3.7, any StopIteration that bubbles out of a generator frame is converted to RuntimeError: generator raised StopIteration. Code that worked for years by letting a helper's StopIteration end a generator now crashes with a different error pointing at a different line. You'll learn the exhaustion protocol cold, the next()-with-default habit, the PEP 479 fix, and how to spot loops that swallow real bugs as fake end-of-data.
The Exhaustion Protocol: next(), __next__, and the Signal
Iteration in Python is a two-method contract. An iterable defines __iter__ to produce an iterator; the iterator defines __next__ to return one item per call and raise StopIteration when empty. Built-in next(it) just calls it., and for-loops wrap that call in a try that catches StopIteration and exits. The whole 10,000-row loop you write as two lines is secretly thousands of __next__()next() calls plus one caught exception.
Calling next() yourself skips the safety net. There's no loop to catch the signal, so the first call past the end raises StopIteration into your code like any other exception. That's correct behavior — the iterator is telling the truth — but it surprises developers who think of next() as returning items forever. It doesn't; it returns items until it can't, then raises.
Internalize this: StopIteration is not an error in the protocol sense. It's the protocol's 'done' message. It becomes an error only when it arrives somewhere unprepared — a manual call with no try, a generator frame under PEP 479, or a loop that mistakes a helper's crash for the data's end. Every fix in this article is about delivering that message to code ready to hear it.
Sketch the call chain on paper when one escapes: which iterator raised, which frame caught or missed it, and which default would have kept the message in its proper channel.
next() doesn't. Every crash or truncation is that message arriving somewhere unprepared.next() With a Default: The One-Habit Fix for Manual Calls
The two-argument form next(iterator, default) converts exhaustion from an exception into a return value. When items remain, you get the next item exactly as before. When the iterator is empty, you get your default instead of a traceback. One extra argument removes the entire crash class for manual calls — polling loops, token parsers, lookahead readers, and merge joins all get simpler.
The default should be something your code can branch on. None works when None never appears as real data. When None is legitimate data — nullable columns, optional tokens — use a module-level sentinel like _MISSING = object() and test with is. Identity comparison against a unique object can't collide with data, so data Nones flow through while true exhaustion takes the sentinel branch.
Apply it everywhere a helper consumes a sub-iterator: token streams, chunk readers, multi-line record assemblers. The refund bug was a single bare next(token_iter) in a helper that met a short memo. A default plus an if-branch would have ended the record instead of the export. Grep your codebase for bare next( calls today — each is a future truncation or page waiting for the right short input.
Log short-record branches with their input offsets so the first truncated memo shows up as a counted event instead of a silent row-count gap.
next() a default and branch on it. Use a unique sentinel when None is valid data so exhaustion stays distinguishable from content.PEP 479: When StopIteration Inside a Generator Becomes RuntimeError
Before Python 3.7, a StopIteration bubbling out of a generator frame silently ended the generator — which meant helper bugs disguised themselves as finished data. PEP 479 closed that hole: now any StopIteration that would escape a generator frame is converted to RuntimeError: generator raised StopIteration, with the original as context. The generator crashes loudly instead of truncating quietly.
In practice this means two things. First, if you see RuntimeError naming a generator, look inside that generator for a bare next() on another iterator or an explicit raise StopIteration — that's the true culprit wearing a new exception type. Second, the fix is never to catch RuntimeError around the generator; it's to stop the StopIteration at its source with a default, or replace raise StopIteration with a plain return, which is the correct way to end a generator.
Migration-era code still carries the old idiom: helpers that raise StopIteration to signal 'done' to a calling generator. Rewrite those helpers to return a sentinel or raise a domain exception like ValueError, and rewrite generators to return instead of raising. The 6-day silent truncation predated full PEP 479 coverage; on modern Python the same bug pages you at 2 a.m. instead — louder, but at least visible.
next() defaults or plain return. PEP 479 turns escapes into RuntimeError — read that as 'find the bare next() inside this generator'.For-Loops Swallow StopIteration: The Silent Truncation Trap
A for-loop stops on the first StopIteration it sees, with no way to ask who sent it. Normally the sender is the loop's own iterator announcing clean end. But any StopIteration raised by code inside the loop body — a helper's bare next(), a property that iterates internally, a nested generator's leak — arrives through the same channel and ends the loop with identical silence. Your 10,000-row export becomes a 9,800-row export with exit code 0.
Defend on two fronts. First, eliminate stray sources: no bare next() inside loop bodies or generator helpers, only defaulted calls with explicit branches. Second, verify completeness outside the loop: compare emitted counts against the source manifest, assert checksums, and page on gaps larger than a small tolerance like 5 rows. The monitoring check 'file exists and non-empty' is what let 9,800 rows pass as healthy for 6 days.
When counts already differ, bisect fast: run the generator to a list and print len() versus the manifest, then binary-search the input for the short record. The culprit is almost always a record shape your test data never included — multi-line memos, empty chunks, trailing delimiters. Add that exact shape to your fixtures so the regression test fails before the next format change ships.
next() from loop bodies and gate every output on source counts, not exit codes.Iterators Are Single-Use: Exhaustion Looks Like Missing Data
Iterators are consumed as you read them — once exhausted, they stay exhausted. Code that passes one iterator to two consumers, or loops over the same generator twice, gets full data the first time and zero rows the second, with no exception to explain the emptiness. The second loop just sees immediate StopIteration and exits, looking exactly like an empty source.
Symptoms include a validation pass that always reports zero rows after the load pass, or a debug print(list(it)) that 'fixes' nothing but empties the iterator before the real loop. The cure is to materialize shared data with list() once when it fits in memory, or to re-create the iterator per pass with a factory function like make_rows() instead of sharing one object. When memory is tight, restructure to a single pass that computes both results together.
Teach your team the one-line diagnostic: print(sum(1 for _ in it)) consumes, so never probe an iterator you still need. If a function receives an iterator parameter, document whether it consumes it — 'consumes the iterator' in the docstring prevents the next caller from reusing the husk.
When a pipeline needs two passes over data too large to materialize, split it into two generator factories fed from the same re-readable source instead of sharing one live iterator.
list() and sharing the list would have validated the actual data.list() or pass a factory that builds a fresh iterator per consumer.Custom Iterators: Raise StopIteration Exactly Once, at the End
When you write __next__ yourself, you own the signal. The contract is narrow: return the next item while items exist, and raise StopIteration exactly when they run out — never to signal errors, skips, or bad records. Raising it early truncates every consumer; raising it for corrupt input teaches loops to treat corruption as completion.
Keep three rules. One: validate inputs in __init__ or the factory, not in __next__, so configuration errors raise ValueError before iteration starts. Two: handle bad records inside __next__ by skipping to the next good item or yielding a quarantined record — reserve StopIteration for true end. Three: make repeated calls after exhaustion keep raising StopIteration rather than restarting or erroring differently, so consumers like list() and for-loops behave uniformly.
Test the edges explicitly: empty source yields zero items then StopIteration, single-item source yields once then stops, and a source with a corrupt middle record still delivers its good neighbors. Those three tests catch every custom-iterator truncation before it meets 10,000 production rows.
Add a fourth test that keeps calling __next__ past exhaustion to lock in the repeat-StopIteration contract your consumers rely on.
Stray StopIteration Silently Dropped 200 Refund Rows
next() on a sub-iterator for multi-line records. Code review had approved the helper years earlier when single-line records always carried the expected tokens. Nobody knew a new multi-line memo format could exhaust the sub-iterator mid-record.- Never call bare
next()inside a generator; one short record in 10,000 turns normal exhaustion into silent truncation of 200 rows. - Gate exports on row counts against the source manifest; 'file exists and non-empty' passed a 9,800-row file that was $18,400 short.
- Treat multi-line input as hostile to token iterators; the memo format change needed a sentinel branch, not a crash-shaped loop exit.
next() callpython -c "it=iter([1,2]); print(next(it), next(it))
try:
next(it)
except StopIteration:
import traceback; traceback.print_exc()". Then fix the call site with a default: python -c "it=iter([1,2]); print(next(it, None), next(it, None), next(it, None))" — the third call returns None instead of raising.next() or helper raising StopIteration inside the generator: grep -n "next(" payouts/export.py | head -20 and python -c "import payouts.export, inspect; print(inspect.getsource(payouts.export.stream))" | grep -n "next\|raise Stop". Fix by giving next() a default or replacing raise StopIteration with return inside generator frames.python -c "print(sum(1 for _ in open('/tmp/refunds.csv'))); print(sum(1 for _ in open('/tmp/export.csv')))" and wc -l /tmp/refunds.csv /tmp/export.csv. Then bisect the generator with python -c "from payouts.export import stream; rows=list(stream()); print(len(rows))" versus the manifest count — a clean exit with a short count means a swallowed stray StopIteration.python -c "it=iter(range(3)); sentinel=object()
while True:
v=next(it, sentinel)
if v is sentinel:
print('clean exhaustion'); break
print(v)". Apply the same pattern at each helper boundary — a helper returning the sentinel means clean end, while an unexpected StopIteration escaping means a bug to fix with a default.python -c "MISSING=object(); it=iter([None, None]); print(next(it, MISSING) is MISSING, next(it, MISSING) is MISSING, next(it, MISSING) is MISSING)". The first two calls return real None items, only the third returns the sentinel — proving exhaustion without confusing data Nones with end-of-data.| File | Command / Code | Purpose |
|---|---|---|
| stopiter_protocol.py | it = iter([10, 20]) | The Exhaustion Protocol |
| stopiter_default.py | _MISSING = object() | next() With a Default |
| stopiter_pep479.py | def gen_with_stray(): | PEP 479 |
| stopiter_swallow.py | def records(raw_lines): | For-Loops Swallow StopIteration |
| stopiter_single_use.py | def make_rows(): | Iterators Are Single-Use |
| stopiter_custom.py | class Batches: | Custom Iterators |
Key takeaways
next() must handle it with a default.next() and branch explicitlyCommon mistakes to avoid
5 patternsCalling bare next() on a sub-iterator inside a generator
Raising StopIteration to end a generator
Trusting exit code 0 as proof of completeness
Reusing one iterator across load and validation passes
list() once or build a fresh iterator per pass via a factory.Using None as the exhaustion default when None is valid data
object() sentinel with is-tests so data Nones flow through.Interview Questions on This Topic
What is StopIteration and which construct depends on it invisibly?
next() repeatedly and exit when StopIteration arrives. Manual next() has no such handler, so the signal reaches your code as an exception.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.
That's Errors. Mark it forged?
5 min read · try the examples if you haven't