Iterators and Iterables: Understanding the Iterator Protocol
The iterator protocol is one of the most elegant and powerful design patterns in Python, underpinning the for loop and enabling memory-efficient iteration over data of any size. Understanding how iterables and iterators work—the difference between them, the two magic methods __iter__() and __next__(), and how to create custom iterators—unlocks advanced Python features like generators and comprehensions. This guide demystifies the protocol with clear explanations and runnable examples.
Key Takeaways
- Iterable vs. Iterator: An iterable is any object with an
__iter__()method (lists, tuples, strings); an iterator is the object that tracks position and implements__next__(). - The Iterator Protocol: The
forloop callsiter()on an iterable to get an iterator, then callsnext()repeatedly untilStopIterationis raised. - Lazy Evaluation: Iterators produce one value at a time only when requested, not upfront, making them memory-efficient for large datasets.
- Custom Iterators: Implement
__iter__()and__next__()in a class to create your own iterator for any custom sequence. - Memory Efficiency: An iterator can yield 1 billion values without storing all of them in memory, unlike a list which requires upfront allocation.
What Is the Iterator Protocol?
The iterator protocol is a design pattern that lets Python's for loop work uniformly across different types of objects—lists, strings, dictionaries, files, and custom classes. It is defined by two methods:
__iter__(self): Called on an iterable; returns an iterator object.__next__(self): Called on an iterator; returns the next value in the sequence. RaisesStopIterationwhen there are no more values.
When you write for item in my_list:, Python silently performs these steps:
- Calls
iter(my_list)→my_list.__iter__()to get an iterator. - Calls
next()on the iterator repeatedly. - Catches
StopIterationto exit the loop cleanly.
Understanding this protocol is foundational to Python mastery.
Iterable vs. Iterator: The Key Distinction
These terms sound similar but have distinct, precise meanings.
What Is an Iterable?
An iterable is any Python object that you can loop over with a for loop. Formally, it is an object with an __iter__() method that returns an iterator.
Examples of iterables:
- Lists, tuples, sets
- Strings
- Dictionaries (iterate over keys by default)
- Files
- Ranges
- Any custom class implementing
__iter__()
Analogy: An iterable is like a book. It contains all the items (pages), but it isn't responsible for keeping track of your reading progress. Every time you start reading, you get a fresh iterator.
my_list = [10, 20, 30]
print(hasattr(my_list, '__iter__')) # Output: True (it's an iterable)
What Is an Iterator?
An iterator is the object that actually performs the iterating—it tracks the current position in the sequence and produces the next value on demand. An iterator is defined by having a __next__() method (and technically also __iter__(), which returns itself).
Analogy: An iterator is like a bookmark. It knows exactly where you are in the book (the iterable) and can provide the next page when you ask.
my_list = [10, 20, 30]
my_iterator = iter(my_list)
print(hasattr(my_iterator, '__next__')) # Output: True (it's an iterator)
print(next(my_iterator)) # Output: 10 (first value)
print(next(my_iterator)) # Output: 20 (second value)
Key relationship: Every iterator is also an iterable (it has an __iter__() method that returns itself), but not every iterable is an iterator. A list is an iterable but not an iterator; calling iter() on it returns an iterator.
The Iterator Protocol in Action
Let's see the protocol in detail with a real example:
my_list = ['a', 'b', 'c']
# Step 1: Get an iterator from the iterable
my_iterator = iter(my_list)
# This calls my_list.__iter__(), which returns a list_iterator object
print(f"Type of my_list: {type(my_list)}")
# Output: <class 'list'>
print(f"Type of my_iterator: {type(my_iterator)}")
# Output: <class 'list_iterator'>
# Step 2: Call next() on the iterator to retrieve items one by one
print(next(my_iterator)) # Output: a
print(next(my_iterator)) # Output: b
print(next(my_iterator)) # Output: c
# Step 3: When all items are exhausted, next() raises StopIteration
try:
next(my_iterator)
except StopIteration:
print("All items have been exhausted!")
# Output: All items have been exhausted!
Under the hood: When you write for letter in my_list:, Python does exactly this—it gets an iterator and calls next() until StopIteration is raised:
# What the for loop does internally:
my_iterator = iter(my_list)
try:
while True:
letter = next(my_iterator)
print(letter)
except StopIteration:
pass # Loop exits cleanly
Why This Matters: Memory Efficiency
The iterator protocol is memory-efficient. Instead of creating a list of all values upfront (which requires allocating memory), an iterator produces values on demand. This is called lazy evaluation.
# Example: range() is an iterable, not a list
big_range = range(1_000_000) # Creates a range object (minimal memory)
# Contrast with:
big_list = list(range(1_000_000)) # Creates a list of 1 million integers (lots of memory!)
# Iterating over big_range is just as fast as big_list, but uses far less memory
for num in big_range:
if num > 100:
break
According to Python documentation, range(1_000_000) uses ~28 bytes; list(range(1_000_000)) uses several megabytes. This is why iterators are preferred for large datasets.
Building a Custom Iterator
The best way to understand the protocol is to implement it yourself. Let's create a class that acts like range(), counting up from a start value to an end value:
class Counter:
"""A simple iterator that counts from start to end."""
def __init__(self, start, end):
"""Initialize the counter with start and end values."""
self.current = start
self.end = end
def __iter__(self):
"""Make this class an iterable by returning itself as an iterator."""
return self
def __next__(self):
"""Make this class an iterator by implementing __next__."""
if self.current >= self.end:
# Signal that iteration is complete
raise StopIteration
else:
# Return current value and prepare the next one
value = self.current
self.current += 1
return value
# --- Using our custom iterator ---
my_counter = Counter(5, 8)
# Because it's an iterable, we can use it in a for loop!
for num in my_counter:
print(num)
# Output:
# 5
# 6
# 7
Step-by-step breakdown:
-
__init__(self, start, end): Stores the start and end values.self.currenttracks the current position. -
__iter__(self): Returnsselfbecause ourCounterobject is both iterable and iterator. It has both__iter__()and__next__()methods. -
__next__(self): Checks ifself.current >= self.end. If yes, raisesStopIterationto signal the end. Otherwise, returns the current value and incrementsself.currentfor the next call.
When we call for num in my_counter:, Python:
- Calls
iter(my_counter)→Counter.__iter__()→ returnsmy_counteritself - Calls
next(my_counter)repeatedly → invokesCounter.__next__() - Catches
StopIterationand exits cleanly
Advanced: Separating Iterable and Iterator Classes
In the example above, the same class was both iterable and iterator. For clarity and flexibility, you can separate them:
class CounterIterable:
"""An iterable that returns a new iterator each time."""
def __init__(self, start, end):
self.start = start
self.end = end
def __iter__(self):
"""Return a fresh iterator."""
return CounterIterator(self.start, self.end)
class CounterIterator:
"""The actual iterator that does the counting."""
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
"""Iterators are also iterable; they return themselves."""
return self
def __next__(self):
if self.current >= self.end:
raise StopIteration
value = self.current
self.current += 1
return value
# --- Usage ---
counter = CounterIterable(5, 8)
# You can iterate multiple times, each with a fresh iterator
for num in counter:
print(num) # 5, 6, 7
for num in counter:
print(num) # 5, 6, 7 again
Advantage: If you call iter() multiple times on CounterIterable, you get fresh iterators, not the same exhausted iterator.
Frequently Asked Questions
What happens if I call next() on an exhausted iterator?
It raises StopIteration every time until you get a fresh iterator:
my_iterator = iter([1, 2, 3])
next(my_iterator) # 1
next(my_iterator) # 2
next(my_iterator) # 3
next(my_iterator) # StopIteration
next(my_iterator) # StopIteration (again)
To iterate again, you need a fresh iterator:
my_iterator = iter([1, 2, 3]) # New iterator
next(my_iterator) # 1
Can I use list() to consume an iterator?
Yes. list() calls next() repeatedly until StopIteration:
my_iterator = iter([10, 20, 30])
my_list = list(my_iterator)
print(my_list) # [10, 20, 30]
# Iterator is now exhausted
next(my_iterator) # StopIteration
What is the difference between an iterator and a generator?
A generator is a special function that returns an iterator. It uses the yield keyword instead of return. Generators are a more concise way to create iterators than defining a class. We'll cover generators in the next article.
Can iterators be reset?
No, built-in iterators cannot be reset once exhausted. You must get a fresh iterator from the original iterable:
my_list = [1, 2, 3]
iterator1 = iter(my_list)
list(iterator1) # Consumes it
next(iterator1) # StopIteration
# Get a fresh iterator
iterator2 = iter(my_list)
next(iterator2) # 1 (fresh start)
What is the performance overhead of iterators vs. lists?
For small datasets, negligible. For large datasets, iterators save massive amounts of memory. A generator that yields 1 billion values uses constant memory; a list of 1 billion values would require gigabytes. Always prefer iterators for large or unbounded sequences.
Practice Challenge
Create a custom iterator class called FibonacciIterator that yields Fibonacci numbers up to a given limit:
- First two Fibonacci numbers: 0, 1
- Each subsequent number is the sum of the previous two: 1, 1, 2, 3, 5, 8, 13, ...
Your iterator should yield numbers until they exceed the limit.
Solution:
class FibonacciIterator:
"""Yields Fibonacci numbers up to a given limit."""
def __init__(self, limit):
self.limit = limit
self.prev, self.curr = 0, 1
def __iter__(self):
return self
def __next__(self):
if self.curr > self.limit:
raise StopIteration
value = self.curr
self.prev, self.curr = self.curr, self.prev + self.curr
return value
# Test
for num in FibonacciIterator(100):
print(num, end=' ')
# Output: 1 1 2 3 5 8 13 21 34 55 89
Conclusion
The iterator protocol is a cornerstone of Python's design. It provides a unified, memory-efficient way to iterate over any sequence or data source. By understanding the distinction between iterables and iterators, and how __iter__() and __next__() work, you unlock the ability to:
- Create memory-efficient custom sequences
- Understand how
forloops work under the hood - Build the foundation for generators and comprehensions
- Write Pythonic code that other developers will instantly understand
Key takeaways:
- An iterable has
__iter__()and returns an iterator. - An iterator has
__next__()and tracks state. - The
forloop callsiter()thennext()untilStopIteration. - Iterators enable lazy evaluation—values produced on demand, not upfront.
- Custom iterators let you create any sequence imaginable.
Next Steps
While creating a class-based iterator is powerful, it can be verbose for simple cases. Python provides a much more concise and elegant way to create iterators called generators, which use the yield keyword. In the next article, explore "Generators: Creating iterators with yield" to see a shorthand that accomplishes the same goals with less boilerplate.
Further Reading
- Python Documentation: Iterator Types — Official reference on iterators.
- PEP 234: Iterators — The original Python Enhancement Proposal that introduced the iterator protocol.
- Real Python: Iterators and Generators — In-depth guide comparing iterators and generators with examples.