Home Python StopIteration in Python: Iterators Explained
Intermediate 5 min · September 23, 2026

StopIteration in Python: Iterators Explained

StopIteration ends iterators when data runs out.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 12 min
  • Writing for-loops and while-loops over lists in Python
  • Calling functions and reading exception tracebacks
  • Basic idea of generators as functions with yield
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
✦ Definition~90s read
What is Python StopIteration Demystified?

StopIteration is the exception Python iterators raise to announce they've run out of items. The iterator protocol has two halves: __iter__ produces an iterator, and __next__ returns one item per call until nothing remains, at which point it raises StopIteration.

Picture a ticket roll at a deli counter.

For-loops hide this entirely by catching the signal and exiting — which is why most developers meet StopIteration only when they call next() directly, write generator code, or inherit a helper that leaks the signal.

The behavior changed meaningfully with PEP 479, fully enforced from Python 3.7. Before it, a StopIteration escaping a generator frame silently ended that generator, letting helper bugs masquerade as finished data. After it, the escape is converted to RuntimeError: generator raised StopIteration, trading silent truncation for a loud crash that still points away from the true line.

Both eras share the same fix location: the bare next() or explicit raise inside generator-adjacent code.

In production StopIteration matters in three shapes. Manual next() calls on token streams and chunk readers crash past the end without a default. Swallowed strays truncate loops with exit code 0, defeating existence-based monitoring. And single-use iterators shared across passes hand the second consumer an already-exhausted husk that looks like empty source data.

The professional response is uniform: default every manual next(), end generators with return, and verify every output against its source count instead of its exit code.

Plain-English First

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.__next__(), 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() 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.

stopiter_protocol.pyPYTHON
1
2
3
4
5
6
7
8
9
it = iter([10, 20])
print(next(it))  # 10
print(next(it))  # 20
try:
    next(it)  # exhausted: raises StopIteration
    print("never reached")
except StopIteration:
    print("exhausted: StopIteration raised as designed")
print("for-loop swallows it:", [x for x in iter([10, 20])])
📊 Production Insight
The refund export's for-loop caught a helper's stray StopIteration as if the data had ended — 200 rows gone with exit code 0. Knowing the loop can't distinguish sources is what turns 'clean finish' into a suspect when counts differ.
🎯 Key Takeaway
StopIteration is the iterator's done message. For-loops catch it; manual 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.

stopiter_default.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_MISSING = object()

def parse_tokens(tokens):
    it = iter(tokens)
    out = []
    while True:
        tok = next(it, _MISSING)
        if tok is _MISSING:
            return out  # clean end, no exception
        out.append(tok.upper())

print(parse_tokens(["a", "b"]))
print(parse_tokens([]))
it = iter([None, None])
print(next(it, _MISSING) is _MISSING, next(it, _MISSING) is _MISSING)
print(next(it, _MISSING) is _MISSING)  # only this is True: real Nones pass through
📊 Production Insight
Line 112's bare next(token_iter) met 200 short memos and raised where a default would have returned. The one-argument-to-two-argument change plus a None branch closed a 6-day silent data loss.
🎯 Key Takeaway
Give every manual 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.

stopiter_pep479.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def gen_with_stray():
    it = iter([])
    yield next(it)  # StopIteration escapes generator frame -> RuntimeError

try:
    list(gen_with_stray())
except RuntimeError as exc:
    print("PEP 479 converted it to", type(exc).__name__ + ":", exc)

def gen_fixed():
    it = iter([])
    first = next(it, None)
    if first is None:
        return  # correct way to end a generator: plain return
    yield first

print("fixed generator yields:", list(gen_fixed()))
📊 Production Insight
The export ran on a path where the helper's StopIteration arrived as clean termination instead of a PEP 479 RuntimeError, hiding 200 missing rows for 6 days. Modern runtimes convert the escape — but only if the StopIteration crosses a generator frame, which is why the default-at-source fix matters more than the version.
🎯 Key Takeaway
Inside generators, never let StopIteration escape: use 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.

stopiter_swallow.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def records(raw_lines):
    it = iter(raw_lines)
    while True:
        line = next(it, None)
        if line is None:
            return
        if line == "MULTI":  # short memo: needs a continuation line
            cont = next(it, None)
            if cont is None:
                yield line + ":<truncated-memo>"  # don't end the export
            else:
                yield line + "+" + cont
        else:
            yield line

src = ["r1", "MULTI", "memo", "r2", "MULTI"]  # trailing short memo
out = list(records(src))
print(len(out), out)
assert len(out) == 4, out  # completeness gate: source had 5 lines, 4 records
⚠ Exit Code 0 Proves Nothing About Completeness
A swallowed StopIteration ends loops cleanly, so green builds and non-empty files can still be 200 rows short. Gate every export on manifest counts, not existence — page when emitted rows differ by more than a handful.
📊 Production Insight
Finance chased the processor for 6 days because the export 'succeeded'. A one-line manifest count gate (10,000 expected, 9,800 produced) would have paged the owning team within 4 minutes of the first short run.
🎯 Key Takeaway
Loops can't tell helper crashes from finished data. Remove bare 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.

stopiter_single_use.pyPYTHON
1
2
3
4
5
6
7
8
9
10
def make_rows():
    return iter(["r1", "r2", "r3"])

shared = make_rows()
first = list(shared)
second = list(shared)  # husk: already exhausted
print("first:", first, "second:", second)
print("factory gives fresh:", list(make_rows()), list(make_rows()))
rows = list(make_rows())  # materialize once, reuse freely
print("materialized:", len(rows), "rows twice:", rows, rows)
📊 Production Insight
A validation step iterated the same generator the loader had consumed, reported zero anomalies on 9,800 rows, and everyone trusted the clean bill of health. Materializing once with list() and sharing the list would have validated the actual data.
🎯 Key Takeaway
Iterators die after one pass — sharing one across two loops gives the second loop zero rows. Materialize with 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.

stopiter_custom.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Batches:
    def __init__(self, rows, size):
        if size <= 0:
            raise ValueError("size must be positive")
        self._rows = list(rows)
        self._size = size
        self._i = 0
    def __iter__(self):
        return self
    def __next__(self):
        if self._i >= len(self._rows):
            raise StopIteration  # true end only
        chunk = self._rows[self._i:self._i + self._size]
        self._i += self._size
        return chunk

print(list(Batches(range(5), 2)))
print(list(Batches([], 2)))
try:
    Batches([1], 0)
except ValueError as exc:
    print("config error, not StopIteration:", exc)
📊 Production Insight
A custom batch reader raised StopIteration on malformed rows instead of skipping them, so every bad record silently ended the file. Moving validation to __init__ and skipping inside __next__ turned truncation into a quarantine count the team reviews weekly.
🎯 Key Takeaway
In custom __next__, StopIteration means 'no more items' and nothing else. Validate config up front, skip or quarantine bad records inline, and test empty, single, and corrupt-middle sources.
● Production incidentPOST-MORTEMseverity: high

Stray StopIteration Silently Dropped 200 Refund Rows

Symptom
The nightly refund export wrote 9,800 rows instead of 10,000 and exited 0 — no alert, no traceback, no retry. Finance reconciled the week $18,400 short and spent 6 days assuming the processor had withheld funds before an engineer diffed row counts. The export log showed a clean finish in the usual 4 minutes, and the monitoring check (file exists and non-empty) passed because 9,800 rows looks healthy.
Assumption
The team assumed the row shortfall came from the processor because the export code hadn't changed in 5 months and the for-loop over the generator looked textbook. The generator called a parsing helper per row, and that helper used 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.
Root cause
For 200 multi-line memo refunds, the helper's manual next(token_iter) hit exhaustion and raised StopIteration inside the generator frame at payouts/export.py line 112. The surrounding for-loop can't distinguish 'generator finished' from 'helper crashed with StopIteration' — both arrive as the same signal — so it stopped cleanly at 9,800 rows. Under PEP 479 this should have become RuntimeError, but the helper's StopIteration was raised in a nested non-generator function called by the generator, and the generator never caught it, so the loop consumed it as normal termination on Python 3.11.
Fix
The fix touched 2 files and backfilled in 31 minutes. In payouts/export.py line 112, the bare next(token_iter) became next(token_iter, None) with an explicit if token is None: return record-so-far branch, so short memos end the record instead of ending the export. A second change in payouts/validate.py asserts the exported row count matches the processor's manifest count (10,000) and pages when they differ by more than 5 rows. The backfill re-exported all 10,000 rows, finance reconciled to the dollar, and the count gate has since caught 2 short batches before they shipped.
Key lesson
  • 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.
Production debug guideFive patterns that separate normal exhaustion from stray signals — with commands that prove which one you have.5 entries
Symptom · 01
Bare StopIteration traceback from a manual next() call
Fix
Reproduce with the failing iterator and confirm exhaustion: python -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.
Symptom · 02
Generator crashes with RuntimeError: generator raised StopIteration
Fix
Find the bare 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.
Symptom · 03
Loop exits early with no error and fewer rows than the source
Fix
Diff counts first: 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.
Symptom · 04
You need to tell normal iterator end from a helper bug in a long pipeline
Fix
Instrument the boundary with sentinel logging: 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.
Symptom · 05
next() with default still misbehaves because None is a valid item
Fix
Use a unique sentinel instead of None: 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.
StopIteration Failure Modes at a Glance
Root CauseHow to ConfirmFixPrevention
Bare next() past the endTraceback at next() line; 3rd call on 2-item iternext(it, default) + branchGrep for bare next( in review
PEP 479 escape from generatorRuntimeError: generator raised StopIterationDefault at source; return, don't raiseBan raise StopIteration in generators
Swallowed stray ends loop earlyClean exit but short counts vs manifestDefaulted helpers + count gateManifest-count alert over 5-row gap
Reused exhausted iteratorFirst loop full, second loop emptylist() once or factory per passDocument consumes-the-iterator
Custom __next__ misuses signalCorrupt row truncates rest of fileSkip/quarantine inline; signal only at endEmpty/single/corrupt-middle tests
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
stopiter_protocol.pyit = iter([10, 20])The Exhaustion Protocol
stopiter_default.py_MISSING = object()next() With a Default
stopiter_pep479.pydef gen_with_stray():PEP 479
stopiter_swallow.pydef records(raw_lines):For-Loops Swallow StopIteration
stopiter_single_use.pydef make_rows():Iterators Are Single-Use
stopiter_custom.pyclass Batches:Custom Iterators

Key takeaways

1
StopIteration is the done message
loops catch it, manual next() must handle it with a default.
2
Pass a sentinel default to every manual next() and branch explicitly
never let exhaustion raise by accident.
3
PEP 479 converts generator-escaping StopIteration to RuntimeError; end generators with return, not raise.
4
For-loops can't distinguish helper crashes from finished data, so gate outputs on manifest counts.
5
Iterators are single-use
materialize shared data once or hand each pass a fresh factory-built iterator.
6
Custom __next__ reserves StopIteration for true end only
validate config early, skip bad records inline.

Common mistakes to avoid

5 patterns
×

Calling bare next() on a sub-iterator inside a generator

Symptom
200 of 10,000 rows vanish with exit 0 — short memos exhaust the token iterator mid-record.
Fix
Use next(it, None) with an explicit short-record branch that ends the record, not the export.
×

Raising StopIteration to end a generator

Symptom
RuntimeError: generator raised StopIteration on Python 3.7+ pointing away from the real line.
Fix
End generators with plain return; signal domain errors with ValueError or a sentinel.
×

Trusting exit code 0 as proof of completeness

Symptom
A 9,800-row file passes 'exists and non-empty' checks while finance reconciles $18,400 short.
Fix
Gate exports on manifest counts and page past a 5-row gap.
×

Reusing one iterator across load and validation passes

Symptom
Validation reports zero anomalies because it iterated the loader's husk — clean bill of health on unchecked data.
Fix
Materialize with list() once or build a fresh iterator per pass via a factory.
×

Using None as the exhaustion default when None is valid data

Symptom
Nullable columns take the end-of-data branch early, dropping real rows with None fields.
Fix
Use a unique _MISSING = object() sentinel with is-tests so data Nones flow through.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is StopIteration and which construct depends on it invisibly?
Q02JUNIOR
How does next(it, default) change exhaustion behavior?
Q03SENIOR
What does PEP 479 change, and what code does it break?
Q04SENIOR
How can a for-loop exit early with no error?
Q05SENIOR
Design an export that can't silently truncate. What are the layers?
Q01 of 05JUNIOR

What is StopIteration and which construct depends on it invisibly?

ANSWER
It's the 'no more items' signal iterators raise on exhaustion. For-loops depend on it: they call next() repeatedly and exit when StopIteration arrives. Manual next() has no such handler, so the signal reaches your code as an exception.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is StopIteration an error or normal behavior?
02
Why does next(it, None) fix my crash?
03
What does 'generator raised StopIteration' mean?
04
Can a for-loop really exit early without any error?
05
Why is my second loop over the same iterator empty?
06
Should I ever raise StopIteration myself?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Errors. Mark it forged?

5 min read · try the examples if you haven't

Previous
Python AttributeError NoneType Fix
8 / 11 · Errors
Next
Python ZeroDivisionError Fix