Understanding StopIteration in Python Generators
The StopIteration exception is a built-in signal in Python that indicates an iterator has been fully exhausted—there are no more items to produce. While this sounds straightforward, the way Python handles StopIteration inside generators changed significantly with PEP 479 (Python 3.5+, enforced in 3.7+), introducing subtle bugs that can be tricky to diagnose. This guide walks you through everything you need to know to identify, fix, and prevent StopIteration-related issues in your generators.
What Exactly Is StopIteration?
StopIteration is a built-in exception class that lives in the builtins module. When an iterator's __next__() method has no more elements to return, it raises StopIteration to tell the consuming construct (a for loop, a comprehension, or a manual next() call) that iteration is complete. The for loop catches this exception internally and exits gracefully.
Here is a minimal iterator class demonstrating the protocol:
class CountDown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
# Usage with a for loop (StopIteration is caught internally)
for num in CountDown(3):
print(num) # Prints 3, 2, 1, 0
# Manual next() calls — StopIteration will propagate to the caller
it = CountDown(2)
print(next(it)) # 2
print(next(it)) # 1
print(next(it)) # 0
print(next(it)) # StopIteration raised here
Generator functions (those using yield) automatically raise StopIteration when they return. The return value, if any, becomes the value attribute of the StopIteration exception. This is how yield from expressions receive sub-generator return values.
Why StopIteration Matters — And Why It Can Break Your Code
Prior to Python 3.5, a generator could raise StopIteration directly inside its code, and the iteration machinery would handle it normally. However, PEP 479 changed this behavior: starting in Python 3.5 with __future__ imports, and enforced by default in Python 3.7+, a StopIteration raised inside a generator is automatically converted into a RuntimeError. This prevents accidental suppression of other exceptions and makes debugging far easier.
The key scenario that breaks is this:
def faulty_generator():
items = [1, 2, 3]
for item in items:
yield item
# Imagine some helper function called here that raises StopIteration
raise StopIteration("No more data") # BUG in Python 3.7+!
# Trying to iterate over this:
for value in faulty_generator():
print(value)
# RuntimeError is raised with: "StopIteration raised inside generator"
This matters because the same issue occurs silently when a generator inadvertently calls a function or expression that triggers a StopIteration inside the generator frame. A common culprit is using next() on an exhausted iterator inside a generator without proper handling:
def flatten_nested(iterables):
for iterable in iterables:
iterator = iter(iterable)
while True:
try:
yield next(iterator) # Fine — we catch StopIteration
except StopIteration:
break # Clean exit from while loop
# But if you forget the try/except:
def flatten_broken(iterables):
for iterable in iterables:
iterator = iter(iterable)
while True:
yield next(iterator) # StopIteration leaks into generator → RuntimeError!
Common Scenarios That Trigger StopIteration Errors
Here are the most frequent real-world situations where StopIteration (and its RuntimeError conversion) cause problems:
1. Calling next() on an exhausted iterator inside a generator without catching StopIteration
def pairwise(seq):
it = iter(seq)
while True:
a = next(it) # May raise StopIteration → RuntimeError
b = next(it) # Same problem
yield (a, b)
# Fix: wrap in try/except or use a default value
def pairwise_fixed(seq):
it = iter(seq)
while True:
try:
a = next(it)
b = next(it)
yield (a, b)
except StopIteration:
return # Clean generator exit
2. Using a helper function that internally raises StopIteration
def first_match(predicate, iterable):
"""Helper that raises StopIteration if no match found."""
for item in iterable:
if predicate(item):
return item
raise StopIteration("No matching item")
def my_generator(data):
for chunk in data:
# If first_match raises StopIteration, it becomes RuntimeError!
best = first_match(lambda x: x > 10, chunk)
yield best * 2
# Fix: either return a sentinel value or catch StopIteration
def first_match_fixed(predicate, iterable, default=None):
for item in iterable:
if predicate(item):
return item
return default # No StopIteration raised
def my_generator_fixed(data):
for chunk in data:
best = first_match_fixed(lambda x: x > 10, chunk)
if best is not None:
yield best * 2
3. Nested generator delegation with yield from that encounters a StopIteration leak
def inner_gen():
yield 1
yield 2
# Some logic that accidentally raises StopIteration
raise StopIteration # RuntimeError in Python 3.7+
def outer_gen():
yield from inner_gen() # RuntimeError propagates here
yield 3
# Fix: use 'return' instead of 'raise StopIteration' in inner_gen
def inner_gen_fixed():
yield 1
yield 2
return # Clean generator exit (implicit StopIteration with no value)
4. Map/filter-style helpers that exhaust iterators without guards
def take(n, iterable):
"""Yield the first n items from iterable."""
it = iter(iterable)
for _ in range(n):
yield next(it) # StopIteration if iterable has fewer than n items
# Fix: catch StopIteration and exit cleanly
def take_fixed(n, iterable):
it = iter(iterable)
for _ in range(n):
try:
yield next(it)
except StopIteration:
return # Iterator exhausted early — stop yielding
How to Properly Handle StopIteration
There are several robust patterns for dealing with iterator exhaustion inside generators. Choose the one that fits your use case:
Pattern A: Catch StopIteration and use return
def safe_zip(*iterables):
"""Zip that stops when the shortest iterable is exhausted."""
iterators = [iter(it) for it in iterables]
while True:
try:
values = [next(it) for it in iterators]
yield tuple(values)
except StopIteration:
return # Generator exits cleanly
Pattern B: Use next() with a default sentinel
SENTINEL = object() # Unique sentinel that will never appear in data
def chunked(iterable, size):
it = iter(iterable)
while True:
chunk = []
for _ in range(size):
item = next(it, SENTINEL)
if item is SENTINEL:
if chunk:
yield chunk
return # Clean exit
chunk.append(item)
yield chunk
Pattern C: Use a flag variable to track exhaustion
def interleave(iterable_a, iterable_b):
it_a, it_b = iter(iterable_a), iter(iterable_b)
exhausted_a, exhausted_b = False, False
while not (exhausted_a and exhausted_b):
if not exhausted_a:
try:
yield next(it_a)
except StopIteration:
exhausted_a = True
if not exhausted_b:
try:
yield next(it_b)
except StopIteration:
exhausted_b = True
Pattern D: Explicitly check for exhaustion before yielding (for custom iterators)
class LazyRange:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current >= self.end:
raise StopIteration # OK in __next__ (not inside a generator)
val = self.current
self.current += 1
return val
# Wrapping in a generator safely:
def generator_wrapper(start, end):
lazy = LazyRange(start, end)
for value in lazy: # For-loop handles StopIteration internally
yield value * 2 # Safe — no StopIteration leak
The Special Case: yield from and Return Values
The yield from expression is designed to delegate to a sub-generator and capture its return value. When a sub-generator exits with return value, Python wraps that value in a StopIteration exception internally and catches it inside the delegating generator. This is perfectly safe and is the intended mechanism:
def sub_generator():
yield 1
yield 2
return "done" # This becomes StopIteration("done") internally
def delegating_gen():
result = yield from sub_generator() # Catches StopIteration, extracts "done"
print(f"Sub-generator returned: {result}") # Prints: Sub-generator returned: done
yield 3
# This works flawlessly in all Python versions
for val in delegating_gen():
print(val) # 1, 2, 3
The critical distinction is: yield from handles the StopIteration internally at the delegation point. The StopIteration never leaks into the generator's own frame. The problem only occurs when you explicitly raise StopIteration inside a generator, or when an unhandled StopIteration propagates up from a nested call that isn't wrapped in yield from.
Debugging StopIteration RuntimeErrors
When you encounter the dreaded RuntimeError: generator raised StopIteration, follow this systematic debugging approach:
Step 1: Identify the exact traceback location
Traceback (most recent call last):
File "example.py", line 10, in my_generator
raise StopIteration
RuntimeError: generator raised StopIteration
The traceback points to the exact line where StopIteration was raised. If the line is an explicit raise StopIteration, replace it with return.
Step 2: If the line is a function call, trace into that function
# The traceback shows the generator line, but the real culprit is inside next()
def problematic():
it = iter([1, 2])
yield next(it) # Fine
yield next(it) # Fine
yield next(it) # StopIteration raised here → RuntimeError
# Add try/except around the suspicious call
def fixed_version():
it = iter([1, 2])
try:
yield next(it)
yield next(it)
yield next(it)
except StopIteration:
return
Step 3: Use a debug wrapper to catch StopIteration before it converts
import sys
def debug_next(iterator, *args):
"""next() wrapper that logs StopIteration for debugging."""
try:
return next(iterator, *args)
except StopIteration as e:
print(f"[DEBUG] StopIteration caught at: {sys._getframe(1).f_code.co_name}",
file=sys.stderr)
raise # Re-raise — this will become RuntimeError in a generator
Best Practices to Prevent StopIteration Issues
- Never explicitly raise StopIteration inside a generator. Use a plain
returnstatement instead. If you need to signal a value, refactor to use a class-based iterator whereStopIterationis expected in__next__. - Always wrap manual next() calls inside generators in try/except StopIteration. This is the single most common source of leaks. If you call
next()on an iterator within a generator, ask yourself: "What happens when this iterator runs out?" - Prefer
forloops over manualnext()calls when possible. Aforloop handlesStopIterationinternally and never leaks it. Reserve manualnext()for cases where you need fine-grained control over multiple iterators simultaneously. - Use
next(it, default)with a sentinel to avoid exceptions entirely. This is cleaner than try/except for single-value fetches and eliminates the StopIteration path altogether. - In helper functions called by generators, return sentinel values or raise custom exceptions instead of StopIteration. For example, return
Noneor raiseValueErrorso the generator can handle it explicitly. - For class-based iterators, raising StopIteration in __next__ is correct and expected. The PEP 479 change only applies to generator functions, not to iterator classes.
- Test your generators with edge cases: empty iterables, single-element iterables, and iterables that are exactly the size of your batch/chunk logic. These boundary conditions are where StopIteration leaks most often occur.
Complete Example: Building a Robust Batch Processor
Let's walk through building a generator that processes items in fixed-size batches, handling all edge cases correctly. This example demonstrates all the best practices together:
SENTINEL = object()
def batch_processor(iterable, batch_size, transform=None):
"""
Yields batches of transformed items from an iterable.
Handles:
- Empty iterables (no batches yielded)
- Partial final batches (yielded as-is)
- StopIteration safety throughout
"""
iterator = iter(iterable)
while True:
batch = []
for _ in range(batch_size):
item = next(iterator, SENTINEL)
if item is SENTINEL:
# Iterator exhausted — yield remaining batch and exit
if batch:
if transform:
batch = [transform(x) for x in batch]
yield batch
return # Clean generator exit
batch.append(item)
# Full batch collected
if transform:
batch = [transform(x) for x in batch]
yield batch
# Test the processor with various edge cases
def test_batch_processor():
# Normal case
result = list(batch_processor(range(10), 3))
print("Normal:", result) # [[0,1,2], [3,4,5], [6,7,8], [9]]
# Empty iterable — no batches
result = list(batch_processor([], 3))
print("Empty:", result) # []
# Exact multiple of batch size
result = list(batch_processor(range(6), 3))
print("Exact:", result) # [[0,1,2], [3,4,5]]
# With transformation
result = list(batch_processor(range(5), 2, lambda x: x * 10))
print("Transformed:", result) # [[0,10], [20,30], [40]]
test_batch_processor()
Notice how this implementation uses next(iterator, SENTINEL) to avoid any StopIteration path inside the generator, and uses a plain return for clean exit. The sentinel pattern is particularly robust because it eliminates the exception entirely from the hot path.
Python Version Compatibility Considerations
If you maintain code that needs to run across Python versions, here is what you need to know:
- Python 2.x:
StopIterationraised inside a generator simply terminates the generator normally. NoRuntimeErrorconversion occurs. - Python 3.0–3.4: Same behavior as Python 2—
StopIterationinside a generator is fine. - Python 3.5–3.6: The conversion to
RuntimeErroroccurs only if you importfrom __future__ import generator_stop. Without the import, the old behavior is preserved. - Python 3.7+: The conversion is mandatory.
StopIterationraised inside a generator always becomesRuntimeError.
To write cross-compatible code, simply adopt the best practices above—they work correctly in all Python versions. Using return instead of raise StopIteration and guarding next() calls are universally safe patterns.
Quick Reference: StopIteration Do's and Don'ts
# ❌ DON'T: Explicitly raise StopIteration in a generator
def bad_gen():
yield 1
raise StopIteration # RuntimeError in 3.7+
# ✅ DO: Use return for clean generator exit
def good_gen():
yield 1
return
# ❌ DON'T: Call next() without a guard inside a generator
def bad_flatten(nested):
it = iter(nested)
while True:
yield next(it) # StopIteration leak
# ✅ DO: Catch StopIteration or use a default
def good_flatten(nested):
it = iter(nested)
while True:
item = next(it, None)
if item is None:
return
yield item
# ✅ DO: Use for-loops when possible (handles StopIteration internally)
def best_flatten(nested):
for item in nested:
yield item
# ❌ DON'T: Let helper functions raise StopIteration into a generator
def helper_bad(iterable):
for item in iterable:
if item > 10:
return item
raise StopIteration # Will leak if called from a generator
# ✅ DO: Return a sentinel or raise a custom exception in helpers
def helper_good(iterable, default=None):
for item in iterable:
if item > 10:
return item
return default
Conclusion
StopIteration is a fundamental part of Python's iteration protocol, but its behavior inside generators changed in a way that can introduce subtle runtime errors. The fix is straightforward once you understand the rule: a generator must never let a StopIteration exception propagate through its frame. Replace explicit raise StopIteration with return, guard all manual next() calls with try/except or sentinel defaults, and prefer for loops wherever possible. By following the patterns and best practices outlined in this guide, you will write generators that are robust, debuggable, and compatible across all modern Python versions—from 3.5 to the latest release.