← Back to DevBytes

Fix 'StopIteration' in Python Generators: Complete Troubleshooting Guide

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

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles