DEV Community

Cover image for The Secret Life of Python: The Iterator Protocol - Why For Loops Are Magic
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Python: The Iterator Protocol - Why For Loops Are Magic

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

Output:

Using for loop:
  1
  2
  3

What Python actually does:
  1
  2
  3

✓ Same result!
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

Counting down:
5 4 3 2 1 

Manual iteration:
  next(): 3
  next(): 2
  next(): 1
  StopIteration raised - done!
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Output:

First loop:
3 2 1 

Second loop:

Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

Playlist: Favorites
  Songs (3):
    - Song A
    - Song B
    - Song C

  First song: Song A
  All songs: ['Song A', 'Song B', 'Song C']
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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__)
Enter fullscreen mode Exit fullscreen mode

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=' ')
Enter fullscreen mode Exit fullscreen mode

Output:

5 4 3 2 1 
3 2 1 
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

Output:

Iterator: [3, 2, 1]
Generator: [3, 2, 1]
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Return the next book in the catalog
  2. 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
"""
Enter fullscreen mode Exit fullscreen mode

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)