Timothy was explaining Python to a colleague from C++ when he got stumped. "So in Python, you can loop over lists, dictionaries, files, strings, ranges, sets... how does for know what to do with all these different types?"
Margaret overheard and smiled. "That's the iterator protocol - one of Python's most elegant designs. Every type that works with for speaks the same language, and you can teach your own objects that language too. Let me show you the magic."
The Puzzle: For Loops Work on Everything
Timothy showed Margaret what confused him:
def demonstrate_for_loop_versatility():
"""For loops work on so many different types!"""
# Loop over a list
for item in [1, 2, 3]:
print(item, end=' ')
print("← list")
# Loop over a string
for char in "hello":
print(char, end=' ')
print("← string")
# Loop over a dictionary
for key in {'a': 1, 'b': 2}:
print(key, end=' ')
print("← dict keys")
# Loop over a file
with open('example.txt', 'w') as f:
f.write('line1\nline2\nline3')
with open('example.txt') as f:
for line in f:
print(line.strip(), end=' ')
print("← file lines")
# Loop over a range
for num in range(3):
print(num, end=' ')
print("← range")
# Loop over a set
for item in {10, 20, 30}:
print(item, end=' ')
print("← set")
demonstrate_for_loop_versatility()
Output:
1 2 3 ← list
h e l l o ← string
a b ← dict keys
line1 line2 line3 ← file lines
0 1 2 ← range
10 20 30 ← set
"See?" Timothy pointed. "The for loop syntax is identical for all these different types. How does Python know how to iterate over each one?"
The Iterator Protocol: Python's Iteration Contract
Margaret sketched out the concept:
"""
The Iterator Protocol: Two simple methods that make iteration work
ITERABLE: Any object with __iter__() method
- Returns an iterator
ITERATOR: Any object with __next__() method
- Returns next item
- Raises StopIteration when done
- Should also have __iter__() that returns self
THE FOR LOOP CONTRACT:
When Python sees: for item in obj:
It actually does:
iterator = iter(obj) # Calls obj.__iter__()
while True:
try:
item = next(iterator) # Calls iterator.__next__()
# loop body
except StopIteration:
break
Every object that works with 'for' implements this protocol!
"""
def demonstrate_for_loop_expansion():
"""Show what a for loop really does"""
items = [1, 2, 3]
print("Using for loop:")
for item in items:
print(f" {item}")
print("\nWhat Python actually does:")
iterator = iter(items) # Get iterator
while True:
try:
item = next(iterator) # Get next item
print(f" {item}")
except StopIteration: # No more items
break
print("\n✓ Same result!")
demonstrate_for_loop_expansion()
Output:
Using for loop:
1
2
3
What Python actually does:
1
2
3
✓ Same result!
Building a Simple Iterator from Scratch
"Let me show you how to create your own iterator," Margaret said.
class CountDown:
"""Simple iterator that counts down from n to 1"""
def __init__(self, start):
self.current = start
def __iter__(self):
"""Return the iterator object (self)"""
return self
def __next__(self):
"""Return the next value or raise StopIteration"""
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
def demonstrate_custom_iterator():
"""Use our custom iterator"""
print("Counting down:")
for num in CountDown(5):
print(num, end=' ')
print("\n")
# Show it works with manual iteration too
print("Manual iteration:")
countdown = CountDown(3)
print(f" next(): {next(countdown)}")
print(f" next(): {next(countdown)}")
print(f" next(): {next(countdown)}")
try:
next(countdown) # Should raise StopIteration
except StopIteration:
print(" StopIteration raised - done!")
demonstrate_custom_iterator()
Output:
Counting down:
5 4 3 2 1
Manual iteration:
next(): 3
next(): 2
next(): 1
StopIteration raised - done!
Timothy studied the code. "So the iterator remembers where it is - the current value - and each time I call next(), it advances and returns the next value."
"Exactly," Margaret confirmed. "But there's a limitation with this design. Watch what happens if you try to iterate twice."
She typed quickly:
countdown = CountDown(3)
print("First loop:")
for num in countdown:
print(num, end=' ')
print("\n\nSecond loop:")
for num in countdown:
print(num, end=' ') # Will this work?
Output:
First loop:
3 2 1
Second loop:
"Nothing on the second loop!" Timothy exclaimed. "Why?"
"Because the iterator exhausted itself on the first loop. The current value is now 0, and it stays 0. If you want to iterate again, you need a fresh iterator. This is where we need to separate the concepts of iterable and iterator."
Separating Iterable from Iterator
Margaret explained an important distinction:
"""
BEST PRACTICE: Separate iterable and iterator
Iterable: Container that can be iterated over
Iterator: The actual object that does the iterating
This allows:
- Multiple simultaneous iterations
- Reusing the iterable
- Cleaner design
"""
class CountDown:
"""Iterable that creates countdown iterators"""
def __init__(self, start):
self.start = start
def __iter__(self):
"""Return a NEW iterator each time"""
return CountDownIterator(self.start)
class CountDownIterator:
"""The actual iterator"""
def __init__(self, start):
self.current = start
def __iter__(self):
"""Iterators should return themselves"""
return self
def __next__(self):
"""Return next value"""
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
def demonstrate_multiple_iterations():
"""Show why separating iterable/iterator matters"""
countdown = CountDown(3)
print("First iteration:")
for num in countdown:
print(num, end=' ')
print()
print("\nSecond iteration (works because we get a fresh iterator!):")
for num in countdown:
print(num, end=' ')
print()
print("\nMultiple simultaneous iterations:")
iter1 = iter(countdown)
iter2 = iter(countdown)
print(f" iter1: {next(iter1)}, {next(iter1)}")
print(f" iter2: {next(iter2)}, {next(iter2)}")
print(" ✓ Independent iterators!")
demonstrate_multiple_iterations()
Output:
First iteration:
3 2 1
Second iteration (works because we get a fresh iterator!):
3 2 1
Multiple simultaneous iterations:
iter1: 3, 2
iter2: 3, 2
✓ Independent iterators!
Built-in Iterables Explained
Timothy wanted to understand how built-in types work. "So all these types - lists, strings, dictionaries - they all implement the iterator protocol?"
"Every single one," Margaret confirmed. "Let me show you what's under the hood for the types you use every day."
def explore_builtin_iterables():
"""Understand how built-in types implement iteration"""
# Lists
my_list = [1, 2, 3]
print("List:")
print(f" Has __iter__: {hasattr(my_list, '__iter__')}")
print(f" iter() returns: {type(iter(my_list))}")
# Strings
my_string = "hello"
print("\nString:")
print(f" Has __iter__: {hasattr(my_string, '__iter__')}")
print(f" iter() returns: {type(iter(my_string))}")
# Dictionaries
my_dict = {'a': 1, 'b': 2}
print("\nDict:")
print(f" Has __iter__: {hasattr(my_dict, '__iter__')}")
print(f" iter() returns: {type(iter(my_dict))}")
print(f" Default iteration: {list(my_dict)}") # Keys
print(f" Values: {list(my_dict.values())}")
print(f" Items: {list(my_dict.items())}")
# Files
with open('temp.txt', 'w') as f:
f.write('line1\nline2')
with open('temp.txt') as f:
print("\nFile:")
print(f" Has __iter__: {hasattr(f, '__iter__')}")
print(f" Iterates over: lines")
explore_builtin_iterables()
Timothy studied the output. "So they all have __iter__, but each returns a different type of iterator - list_iterator, str_iterator, dict_keyiterator. Python has specialized iterators for each built-in type."
"Exactly," Margaret said. "And notice something interesting about dictionaries - by default they iterate over keys, but you can get iterators for values or items too. Same protocol, different iterators."
"That's elegant," Timothy observed. "One protocol, but each type implements it in the way that makes sense for that type."
"Now let me show you how to build your own iterable collection," Margaret said, opening a new file.
Real-World Use Case 1: Custom Collection
"Okay," Timothy said, "I understand the protocol. But how would I actually use this in real code?"
Margaret smiled. "Perfect question. Let's build something practical - a music playlist that you can iterate over."
class Playlist:
"""Music playlist that can be iterated over"""
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __iter__(self):
"""Return iterator over songs"""
return iter(self.songs) # Delegate to list's iterator
def __len__(self):
return len(self.songs)
def demonstrate_custom_collection():
"""Show custom iterable collection"""
playlist = Playlist("Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
print(f"Playlist: {playlist.name}")
print(f" Songs ({len(playlist)}):")
for song in playlist:
print(f" - {song}")
# Works with all iteration tools
print(f"\n First song: {next(iter(playlist))}")
print(f" All songs: {list(playlist)}")
demonstrate_custom_collection()
Output:
Playlist: Favorites
Songs (3):
- Song A
- Song B
- Song C
First song: Song A
All songs: ['Song A', 'Song B', 'Song C']
Timothy ran the code and nodded approvingly. "That's clean. The Playlist class is iterable, so I can use it with for loops, list(), next() - anything that expects an iterable. And I just delegated to the list's iterator instead of writing my own."
"Right," Margaret confirmed. "You don't always need to write custom iterator classes. If you have an internal collection, just delegate to its iterator. But sometimes you need more control..."
Real-World Use Case 2: Pagination
"Here's a more sophisticated pattern," Margaret said, pulling up a new example. "Imagine you're fetching data from an API that returns results in pages. You don't want to load everything into memory at once - you want to fetch each page as needed."
Timothy leaned forward. "Like lazy loading?"
"Exactly. Watch this:"
class PaginatedAPI:
"""Iterator for paginated API results"""
def __init__(self, page_size=10, total_items=100):
self.page_size = page_size
self.total_items = total_items
self.current_item = 0
def __iter__(self):
return self
def __next__(self):
if self.current_item >= self.total_items:
raise StopIteration
# Simulate fetching a page of results
page_start = self.current_item
page_end = min(self.current_item + self.page_size, self.total_items)
items = list(range(page_start, page_end))
self.current_item = page_end
return items
def demonstrate_pagination():
"""Show pagination iterator"""
print("Fetching results in pages:")
api = PaginatedAPI(page_size=25, total_items=87)
for page_num, page in enumerate(api, 1):
print(f" Page {page_num}: {len(page)} items (first: {page[0]}, last: {page[-1]})")
print(f"\n✓ Fetched all items without loading everything into memory!")
demonstrate_pagination()
Output:
Fetching results in pages:
Page 1: 25 items (first: 0, last: 24)
Page 2: 25 items (first: 25, last: 49)
Page 3: 25 items (first: 50, last: 74)
Page 4: 12 items (first: 75, last: 86)
✓ Fetched all items without loading everything into memory!
"That's brilliant!" Timothy exclaimed. "Each call to next() fetches the next page. I'm not loading all 87 items at once - just 25 at a time. This would work with a real API too."
"Exactly. This is how database cursors work, how API clients handle pagination, how you'd process large datasets. The iterator protocol makes lazy loading natural."
"Can iterators be infinite?" Timothy asked suddenly.
Margaret grinned. "I was hoping you'd ask that."
Real-World Use Case 3: Infinite Sequences
"Watch this carefully," Margaret said, starting to type. "We're going to create an iterator that never ends."
"Never ends?" Timothy looked worried. "Won't that crash?"
"Not if you use it carefully. Let me show you:"
class FibonacciIterator:
"""Infinite Fibonacci sequence"""
def __init__(self):
self.a, self.b = 0, 1
def __iter__(self):
return self
def __next__(self):
value = self.a
self.a, self.b = self.b, self.a + self.b
return value
class Counter:
"""Infinite counter starting from n"""
def __init__(self, start=0):
self.current = start
def __iter__(self):
return self
def __next__(self):
value = self.current
self.current += 1
return value
def demonstrate_infinite_iterators():
"""Show infinite sequences (safely!)"""
print("First 10 Fibonacci numbers:")
fib = FibonacciIterator()
for i, num in enumerate(fib):
print(num, end=' ')
if i >= 9: # Stop after 10
break
print()
print("\nCounter starting from 100 (first 5):")
counter = Counter(100)
for i, num in enumerate(counter):
print(num, end=' ')
if i >= 4:
break
print()
print("\n💡 Infinite iterators + break = controlled infinite sequences!")
demonstrate_infinite_iterators()
Output:
First 10 Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34
Counter starting from 100 (first 5):
100 101 102 103 104
💡 Infinite iterators + break = controlled infinite sequences!
Timothy stared at the code in wonder. "So the iterator never raises StopIteration - it just keeps going forever. But I control when to stop with break or by only taking what I need."
"Right. Infinite iterators are surprisingly useful," Margaret said. "ID generators, stream processors, event loops - lots of real-world use cases. The key is: the iterator protocol doesn't require you to end. You can go forever."
"That's mind-bending," Timothy admitted. "What else can the iter() function do?"
"Ah," Margaret said with a mysterious smile. "There's a secret second form most people don't know about."
The iter() Function's Two Forms
Margaret pulled up Python's documentation. "The iter() function you've been using has a two-argument form that's incredibly useful but rarely seen."
"Two arguments?" Timothy asked.
"Watch:"
def demonstrate_iter_with_sentinel():
"""iter() has a two-argument form!"""
# Form 1: Normal iterator
# iter(iterable) → iterator
# Form 2: Callable with sentinel
# iter(callable, sentinel) → iterator
# Calls callable repeatedly until it returns sentinel
import random
random.seed(42)
def roll_die():
"""Simulate rolling a die"""
return random.randint(1, 6)
print("Rolling die until we get a 6:")
# iter(callable, sentinel) - calls roll_die() until it returns 6
for roll in iter(roll_die, 6):
print(f" Rolled: {roll}")
print(" Got 6! Stopped.\n")
# Another example: Reading file blocks
def read_block():
"""Simulate reading blocks from a file"""
blocks = [b'data1', b'data2', b'', b'data3']
return blocks.pop(0) if blocks else b''
print("Reading blocks until empty:")
for block in iter(read_block, b''):
print(f" Block: {block}")
print(" Empty block! Stopped.")
demonstrate_iter_with_sentinel()
"That's clever!" Timothy said. "Instead of creating an iterator from an iterable, you're creating an iterator from a function call. It keeps calling the function until it gets the sentinel value."
"Exactly. The two-argument form is: iter(callable, sentinel). Call the function repeatedly until it returns the sentinel, then stop."
"I can see that being useful for file reading or API polling," Timothy mused. "Keep calling until you get an empty response or error condition."
"Those are perfect use cases," Margaret confirmed. "Now, there's one critical thing you need to understand about iterators - something that trips up every Python developer at some point."
Iterator Exhaustion
"Here's a gotcha that will bite you eventually," Margaret said, pulling up a new example. "Pay close attention to this."
Timothy leaned in, sensing this was important.
def demonstrate_iterator_exhaustion():
"""Iterators can only be used once"""
my_list = [1, 2, 3]
# Lists are iterables (can create multiple iterators)
print("List (iterable):")
print(f" First loop: {list(my_list)}")
print(f" Second loop: {list(my_list)}")
print(" ✓ Works multiple times!\n")
# Iterators exhaust
my_iterator = iter([1, 2, 3])
print("Iterator:")
print(f" First loop: {list(my_iterator)}")
print(f" Second loop: {list(my_iterator)}") # Empty!
print(" ✗ Iterator exhausted after first use!\n")
# Checking if exhausted
my_iterator = iter([1, 2, 3])
print("Checking exhaustion:")
print(f" Has items: {next(my_iterator, 'EMPTY') != 'EMPTY'}")
print(f" Next item: {next(my_iterator)}")
print(f" Next item: {next(my_iterator)}")
print(f" Next item: {next(my_iterator, 'EMPTY')}") # Exhausted
demonstrate_iterator_exhaustion()
Output:
List (iterable):
First loop: [1, 2, 3]
Second loop: [1, 2, 3]
✓ Works multiple times!
Iterator:
First loop: [1, 2, 3]
Second loop: []
✗ Iterator exhausted after first use!
Checking exhaustion:
Has items: True
Next item: 2
Next item: 3
Next item: EMPTY
"Oh!" Timothy exclaimed, looking at the output. "The list works multiple times because it creates a fresh iterator each time. But the iterator itself can only be used once - after it's exhausted, it's done."
"Exactly," Margaret confirmed. "This is the most common iterator mistake. People store an iterator, use it once, then wonder why it's empty the second time."
Timothy nodded, making a note. "So if I need to iterate multiple times, store the iterable, not the iterator. The iterable can create fresh iterators as needed."
"Perfect understanding. Now let me show you Python's built-in iterator toolbox."
Built-in Iterator Tools
"Python has a module called itertools that's full of useful iterator utilities," Margaret said, pulling up the documentation. "These tools let you compose iterators in powerful ways."
"Like what?" Timothy asked.
"Let me show you the greatest hits:"
import itertools
def demonstrate_iterator_tools():
"""Python's built-in iterator tools"""
# itertools.count - infinite counter
print("itertools.count (first 5):")
for i, num in enumerate(itertools.count(10, 2)):
print(num, end=' ')
if i >= 4:
break
print()
# itertools.cycle - repeat sequence infinitely
print("\nitertools.cycle (first 10):")
for i, item in enumerate(itertools.cycle(['A', 'B', 'C'])):
print(item, end=' ')
if i >= 9:
break
print()
# itertools.chain - combine iterables
print("\nitertools.chain:")
combined = itertools.chain([1, 2], ['a', 'b'], [10, 20])
print(f" {list(combined)}")
# itertools.islice - slice an iterator
print("\nitertools.islice (items 2-5 from infinite counter):")
print(f" {list(itertools.islice(itertools.count(), 2, 6))}")
# zip - iterate multiple sequences together
print("\nzip:")
names = ['Alice', 'Bob', 'Charlie']
ages = [25, 30, 35]
for name, age in zip(names, ages):
print(f" {name}: {age}")
# enumerate - add indices
print("\nenumerate:")
for i, fruit in enumerate(['apple', 'banana', 'cherry'], start=1):
print(f" {i}. {fruit}")
demonstrate_iterator_tools()
Output:
itertools.count (first 5):
10 12 14 16 18
itertools.cycle (first 10):
A B C A B C A B C A
itertools.chain:
[1, 2, 'a', 'b', 10, 20]
itertools.islice (items 2-5 from infinite counter):
[2, 3, 4, 5]
zip:
Alice: 25
Bob: 30
Charlie: 35
enumerate:
1. apple
2. banana
3. cherry
Timothy was impressed. "These are like building blocks. count gives you infinite sequences, cycle repeats, chain combines, islice slices an iterator without loading it all into memory. You can compose these to solve complex problems."
"Exactly," Margaret said. "And they're all lazy - nothing is computed until you actually iterate. Now, let's make absolutely sure you understand the fundamental distinction we've been dancing around."
Iterator vs Iterable: The Key Difference
"I want to drill this down once and for all," Margaret said, opening a comparison. "What's the difference between an iterable and an iterator?"
Timothy thought for a moment. "An iterable... can be iterated over. An iterator... does the iterating?"
"Close. Let me show you the technical distinction:"
def demonstrate_iterator_vs_iterable():
"""Understand the crucial difference"""
# List is ITERABLE (not iterator)
my_list = [1, 2, 3]
print("List (iterable):")
print(f" Has __iter__: {hasattr(my_list, '__iter__')}")
print(f" Has __next__: {hasattr(my_list, '__next__')}")
print(f" Is its own iterator: {iter(my_list) is my_list}")
# Get iterator from iterable
my_iterator = iter(my_list)
print("\nIterator from list:")
print(f" Has __iter__: {hasattr(my_iterator, '__iter__')}")
print(f" Has __next__: {hasattr(my_iterator, '__next__')}")
print(f" Is its own iterator: {iter(my_iterator) is my_iterator}")
print("\nKEY DIFFERENCE:")
print(" Iterable: Can create iterators (__iter__)")
print(" Iterator: Does the iteration (__next__)")
print(" Iterator is also iterable (returns self from __iter__)")
demonstrate_iterator_vs_iterable()
Output:
List (iterable):
Has __iter__: True
Has __next__: False
Is its own iterator: False
Iterator from list:
Has __iter__: True
Has __next__: True
Is its own iterator: True
KEY DIFFERENCE:
Iterable: Can create iterators (__iter__)
Iterator: Does the iteration (__next__)
Iterator is also iterable (returns self from __iter__)
The Pythonic Shortcut: A Glimpse at Generators
Timothy looked at all the iterator code they'd written. "This is powerful, but writing __iter__ and __next__ and tracking state with self.current - it feels like a lot of boilerplate for something so common."
"You're absolutely right," Margaret said with a knowing smile. "And Python's designers thought the same thing. That's why they created generators - a shortcut for creating iterators using the yield keyword."
"Yield?" Timothy asked.
"Watch this." Margaret opened a new window and typed:
def countdown_generator(n):
"""Generator version - much simpler!"""
while n > 0:
yield n
n -= 1
# Use it exactly like our iterator
for num in countdown_generator(5):
print(num, end=' ')
print()
# Works multiple times
for num in countdown_generator(3):
print(num, end=' ')
Output:
5 4 3 2 1
3 2 1
Timothy stared. "That's it? Just yield instead of all that __iter__ and __next__ code?"
"That's it," Margaret confirmed. "When you use yield, Python automatically creates an iterator for you. It handles __iter__, __next__, state preservation, and raising StopIteration. Everything we just learned about iterators - Python does it for you."
"So why did we learn all that iterator protocol stuff if generators are easier?"
Margaret leaned back. "Because understanding the iterator protocol shows you how Python iteration actually works. Generators are magical-looking until you realize they're just syntactic sugar for the iterator protocol. Now you understand both the mechanism and the convenience."
She pulled up a comparison:
# Iterator class - explicit protocol
class CountDown:
def __init__(self, start):
self.start = start
def __iter__(self):
return CountDownIterator(self.start)
class CountDownIterator:
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
# Generator - protocol handled automatically
def countdown_generator(start):
while start > 0:
yield start
start -= 1
# Both work identically
print("Iterator:", list(CountDown(3)))
print("Generator:", list(countdown_generator(3)))
Output:
Iterator: [3, 2, 1]
Generator: [3, 2, 1]
"They produce the exact same result," Timothy observed. "The generator is just... cleaner."
"Much cleaner. And that's why generators are the Pythonic way to create custom iterators in practice. But now you understand why they work - they're implementing the iterator protocol under the hood."
Margaret stood up. "In our next conversation, we'll dive deep into generators - how they work, why they're memory-efficient, and all the powerful things you can do with them. But first, let me show you some common mistakes to avoid with iterators."
Common Pitfalls
"Before you go off and iterator-ify everything," Margaret said with a warning tone, "let me show you the traps that catch everyone."
Timothy pulled out his notebook. "Hit me with the gotchas."
"First one's a classic," Margaret said, typing:
def pitfall_1_modifying_while_iterating():
"""Pitfall: Modifying a list while iterating"""
# ❌ WRONG - Modifying during iteration
items = [1, 2, 3, 4, 5]
print("Attempting to remove even numbers:")
try:
for item in items:
if item % 2 == 0:
items.remove(item) # Modifies during iteration! Can skip items or raise RuntimeError
print(f" Result: {items}")
print(" ✗ Missed item 4! (Iterator got confused)")
except RuntimeError as e:
print(f" ✗ RuntimeError: {e}")
# ✓ CORRECT - Iterate over copy
items = [1, 2, 3, 4, 5]
print("\nIterating over copy:")
for item in items[:]: # Slice creates copy
if item % 2 == 0:
items.remove(item)
print(f" Result: {items}")
print(" ✓ Correct!")
# ✓ CORRECT - List comprehension
items = [1, 2, 3, 4, 5]
print("\nList comprehension:")
items = [item for item in items if item % 2 != 0]
print(f" Result: {items}")
print(" ✓ Best approach!")
def pitfall_2_iterator_consumed():
"""Pitfall: Using iterator twice"""
iterator = iter([1, 2, 3])
print("\nFirst consumption:")
result1 = list(iterator)
print(f" {result1}")
print("\nSecond consumption:")
result2 = list(iterator)
print(f" {result2}")
print(" ✗ Iterator was already exhausted!")
def pitfall_3_infinite_without_break():
"""Pitfall: Infinite iterator without break"""
print("\nInfinite iterator needs break:")
print(" for i in itertools.count():")
print(" if i > 5: break # ← Must have stopping condition!")
pitfall_1_modifying_while_iterating()
pitfall_2_iterator_consumed()
pitfall_3_infinite_without_break()
Timothy reviewed his notes. "So the big three are: don't modify what you're iterating over, remember that iterators exhaust, and always have a stopping condition for infinite iterators."
"Those three alone will save you hours of debugging," Margaret confirmed. "Now let me show you how to test iterator behavior properly."
Testing Iterator Behavior
"When you're writing custom iterators," Margaret said, pulling up a test file, "you need to verify they follow the protocol correctly."
"What should I test?" Timothy asked.
"Let me show you the essential tests:"
import pytest
def test_custom_iterator():
"""Test a custom iterator"""
class SimpleIterator:
def __init__(self, data):
self.data = data
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.data):
raise StopIteration
value = self.data[self.index]
self.index += 1
return value
iterator = SimpleIterator([1, 2, 3])
# Test iteration
assert next(iterator) == 1
assert next(iterator) == 2
assert next(iterator) == 3
# Test StopIteration
with pytest.raises(StopIteration):
next(iterator)
def test_iterable_reusable():
"""Test that iterable can be iterated multiple times"""
class Reusable:
def __init__(self, data):
self.data = data
def __iter__(self):
return iter(self.data)
obj = Reusable([1, 2, 3])
# First iteration
result1 = list(obj)
assert result1 == [1, 2, 3]
# Second iteration (should work!)
result2 = list(obj)
assert result2 == [1, 2, 3]
def test_iterator_exhaustion():
"""Test that iterator exhausts"""
iterator = iter([1, 2, 3])
# Exhaust iterator
list(iterator)
# Should be empty now
assert list(iterator) == []
# Run with: pytest test_iterators.py -v
The Library Metaphor
Margaret brought it back to the library:
"Think of iteration like checking out books from a catalog," she said.
"The catalog itself (an iterable) isn't the process of checking out books - it's the thing that enables checking out. When you start checking out books, you get a bookmark (an iterator) that tracks your progress through the catalog.
"The bookmark has two jobs:
- Return the next book in the catalog
- Remember where you are
"You can have multiple bookmarks in the same catalog (multiple iterators from one iterable), and each tracks its position independently. Once a bookmark reaches the end, it can't be reused - you need a new bookmark to go through the catalog again.
"This is why for loops work with so many types. Every type implements the same 'catalog and bookmark' protocol: provide a bookmark (__iter__), and the bookmark knows how to move forward (__next__)."
Key Takeaways
Margaret summarized:
"""
ITERATOR PROTOCOL KEY TAKEAWAYS:
1. Two key methods:
- __iter__(): Returns an iterator
- __next__(): Returns next item or raises StopIteration
2. Iterable vs Iterator:
- Iterable: Can create iterators (has __iter__)
- Iterator: Does the iterating (has __next__ and __iter__)
- Iterator's __iter__ should return self
3. For loops use this protocol:
- for item in obj: → iter(obj) then next() repeatedly
- Works with any object implementing the protocol
4. Separation of concerns:
- Iterable: The container/sequence
- Iterator: The state of iteration
- Allows multiple simultaneous iterations
5. Iterator exhaustion:
- Iterators can only be used once
- Iterables can create fresh iterators
- Store iterables, not iterators
6. Generators: The Pythonic shortcut:
- Use 'yield' instead of __iter__ and __next__
- Python handles the protocol automatically
- Cleaner, more readable code
- Next article will explore generators deeply
7. Real-world uses:
- Custom collections
- Pagination (lazy loading)
- Infinite sequences
- Streaming data
- File processing
8. Built-in tools:
- itertools: count, cycle, chain, islice, etc.
- zip: combine sequences
- enumerate: add indices
- iter(callable, sentinel): advanced form
9. Common pitfalls:
- Modifying while iterating (can skip items or raise RuntimeError)
- Reusing exhausted iterators
- Infinite iterators without break
- Confusing iterable with iterator
10. Benefits:
- Memory efficient (one item at a time)
- Lazy evaluation (compute on demand)
- Uniform interface for different types
- Enables powerful composition
11. When to use:
- Custom collections
- Large datasets (don't load all at once)
- Infinite sequences
- API pagination
- File/stream processing
"""
Timothy nodded, understanding. "So the iterator protocol is why for is so versatile. Every type speaks the same language - __iter__ to start, __next__ to continue, StopIteration to finish. And I can make my own types speak this language too!"
"Exactly," Margaret said. "The iterator protocol is one of Python's most elegant designs. Two simple methods, and suddenly any object can work with for loops, list comprehensions, next(), zip(), and all the iterator tools."
"And generators," Timothy added, "are just the convenient way to implement this protocol without all the boilerplate."
"Right. Which is why our next conversation will be all about generators - how they work under the hood, why they're so memory-efficient, and all the powerful patterns you can build with them. But now you understand the foundation: the iterator protocol that makes it all possible."
With that knowledge, Timothy could create custom iterables, understand how iteration really works, recognize the protocol in action throughout Python, and appreciate why generators are such a powerful feature - because they automate the protocol he now understood deeply.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)