Introduction
In the previous lesson, we successfully made a custom object iterable by implementing the iteration protocol. We did this by creating a main iterable class and a separate, stateful iterator class. While correct, you likely noticed the boilerplate involved: managing an index, manually raising StopIteration, and defining two full classes just to enable a for loop. This is precisely the kind of verbose ceremony we want to leave behind.
This lesson introduces a more concise and Pythonic solution: generators. Our goal is to use the yield keyword to create clean, correct, and readable iterators directly, without the need for manual stateful classes. By the end of this lesson, you'll be able to replace a verbose iterator class with a simple, elegant generator function.
From Verbose Class to Concise Generator
Let's start by directly comparing the two approaches. In the last lesson, we saw how to build an iterator class that keeps track of its state (_index) and implements __next__. The video below first recaps that class-based approach and then immediately introduces the generator-based alternative, which is the core of today's lesson.
Python Tutorial: Iterators and Iterables - What Are They and How Do They Work?
This video from Corey Schafer will demonstrate the 'before' and 'after'. First, it shows a custom MyRange class built as a stateful iterator. Then, it shows how to achieve the exact same result with a much simpler generator function using yield.
Watch from 10:01 to 18:04. Pay close attention to the transition at 14:48. Notice how the generator function my_range replaces the entire MyRange class, eliminating the need for __init__, __iter__, and __next__.
The difference is stark. The generator function is not just shorter; it's more expressive. The logic for producing the sequence is all in one place, unencumbered by the boilerplate of the iterator protocol.
The Magic of yield
So, what is happening? The yield keyword is the key. When you include yield in a function, it's no longer a regular function. It becomes a generator function.
Here's the mechanism:
- Calling a generator function does not execute it. Instead, it returns a generator object.
- This generator object is a fully-formed iterator. Python automatically implements the
__iter__and__next__methods for you. - Each time
next()is called on the generator object, the function's code executes until it hits ayieldstatement. - The
yieldstatement does two things: it provides the value to the caller, and it pauses the function's execution, preserving its entire local state (variables, loops, etc.). - When
next()is called again, the function resumes right after theyieldstatement where it left off. - When the function completes (either by reaching the end or a
returnstatement), it automatically raisesStopIteration.
This article explains the process clearly.
Iterables vs. Iterators vs. Generators
Let's read a breakdown of how yield works. This article by Vincent Driessen uses a Fibonacci sequence example to illustrate the pause-and-resume nature of generators.
Read the section titled 'Generators'. Focus on the step-by-step explanation of what happens when next() is called on the fib generator. This will solidify your understanding of the execution flow.
The Idiomatic Pattern: Using yield within __iter__
While you can use a standalone generator function, the most common and powerful pattern is to place the generator logic directly inside your container class's __iter__ method.
By doing this, the __iter__ method itself becomes a generator function. Every time it's called (e.g., by a for loop), it returns a new, independent generator object. This elegantly solves the shared state problem we discussed in the last lesson, without needing a separate iterator class.
Let's refactor the Sentence class from our previous exercise:
Before (Two-Class Pattern):
class _SentenceIterator:
def __init__(self, words):
self.words = words
self.index = 0
def __next__(self):
if self.index >= len(self.words):
raise StopIteration
current_word = self.words[self.index]
self.index += 1
return current_word
def __iter__(self):
return self
class Sentence:
def __init__(self, text: str):
self.words = text.split()
def __iter__(self):
# Returns an instance of the helper class
return _SentenceIterator(self.words)
After (Generator Pattern):
class Sentence:
def __init__(self, text: str):
self.words = text.split()
def __iter__(self):
# __iter__ is now a generator function!
for word in self.words:
yield word
# No need for a separate iterator class.
# No need for self.index state.
# No need to manually raise StopIteration.
This is the Pythonic way. The _SentenceIterator class is now completely unnecessary. The __iter__ method concisely expresses the iteration logic.
This article from Trey Hunner demonstrates this exact pattern and declares generators as the way to make iterators.
How to make an iterator in Python
This article reinforces the idea that generators are the standard tool for creating iterators and shows how to use them to implement __iter__.
Read the sections 'Generators can help when making iterables too' and 'Generators are the way to make iterators'. The Point class example is a perfect illustration of defining __iter__ as a generator function.
Generator Expressions: The Comprehension-style Iterator
For very simple cases, you can create a generator without even a def statement using a generator expression. The syntax is identical to a list comprehension, but using parentheses () instead of square brackets [].
- List Comprehension (builds a list in memory):
[x*x for x in range(10)] - Generator Expression (creates a lazy iterator):
(x*x for x in range(10))
Generator expressions are memory-efficient for simple transformations because they produce items one at a time, just like a generator function.
# Create a large list in memory
list_comp = [i for i in range(10_000_000)]
# list_comp consumes significant memory
# Create a generator object (consumes almost no memory)
gen_exp = (i for i in range(10_000_000))
# You can iterate over it just like any other iterator
print(next(gen_exp)) # 0
print(next(gen_exp)) # 1
# ...and so on
We'll dive deeper into comprehensions in a later module, but it's important to recognize this syntax now as another way to create generators.
Summary: Custom Iterators vs. Generators
The choice between a manual iterator class and a generator is clear in most cases. This table summarizes the trade-offs.

In short: always prefer a generator unless you have a compelling reason not to. A generator is simpler to write, easier to read, and less prone to errors.
This diagram visualizes where generators fit within Python's iteration landscape.

Exercise: Refactor to Use yield
Your task is to refactor the following ReversedSequence class. It currently uses a verbose, stateful iterator class. Your goal is to eliminate the _ReversedSequenceIterator class and implement the __iter__ method as a generator function.
# original_code.py
class _ReversedSequenceIterator:
"""A stateful iterator that yields items from a sequence in reverse."""
def __init__(self, sequence):
self.sequence = sequence
self.index = len(sequence)
def __next__(self):
if self.index == 0:
raise StopIteration
self.index -= 1
return self.sequence[self.index]
def __iter__(self):
return self
class ReversedSequence:
"""A container that allows iterating over a sequence in reverse."""
def __init__(self, sequence):
self.sequence = sequence
def __iter__(self):
# TODO: This implementation is too verbose.
# Replace it with a generator function using yield.
return _ReversedSequenceIterator(self.sequence)
# --- Tests ---
data = [10, 20, 30, 40]
rev_seq = ReversedSequence(data)
# Test 1: Iteration works
assert list(rev_seq) == [40, 30, 20, 10]
# Test 2: Independent iteration
iter1 = iter(rev_seq)
iter2 = iter(rev_seq)
assert next(iter1) == 40
assert next(iter1) == 30
assert next(iter2) == 40, "Iterators should be independent"
print("All tests passed! (with the original code)")
print("Now, refactor it to be more Pythonic.")
Your goal is to modify ReversedSequence so the tests still pass, but the _ReversedSequenceIterator class is no longer needed.
Click to see the Pythonic solution
# refactored_code.py
class ReversedSequence:
"""A container that allows iterating over a sequence in reverse."""
def __init__(self, sequence):
self.sequence = sequence
def __iter__(self):
"""
Implementation using a generator function.
It's more concise and readable.
"""
# A simple way to iterate backwards is to use reversed()
# but for demonstration, we can use a while loop.
index = len(self.sequence)
while index > 0:
index -= 1
yield self.sequence[index]
# --- Tests ---
data = [10, 20, 30, 40]
rev_seq = ReversedSequence(data)
# Test 1: Iteration works
assert list(rev_seq) == [40, 30, 20, 10]
# Test 2: Independent iteration
iter1 = iter(rev_seq)
iter2 = iter(rev_seq)
assert next(iter1) == 40
assert next(iter1) == 30
assert next(iter2) == 40, "Iterators should be independent"
print("All tests passed with the refactored code!")
Conclusion
You've now mastered a cornerstone of idiomatic Python. By replacing manual iterator classes with generators, you write code that is not only shorter but also clearer and more maintainable.
Key Takeaways:
- A function containing
yieldis a generator function that produces a generator object (an iterator). yieldpauses a function's execution and saves its state, resuming on the next call tonext().- The most Pythonic way to make a container iterable is to implement its
__iter__method as a generator function. - This approach automatically handles state management and
StopIteration, eliminating boilerplate. - Generator expressions
(item for item in iterable)offer a concise syntax for simple, lazy iteration.
In the next lesson, we will continue expanding our custom objects' capabilities. We'll implement the container protocols __len__ and __contains__, allowing the built-in len() function and the in operator to work seamlessly with your types.