Advanced · Lesson 50 of 94

Iterators and the iteration protocol

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

A for loop looks like magic: hand it a list, a string, a dictionary, a file, or an object you wrote, and it walks the items. Underneath is one small protocol. The loop calls iter(thing) to get an iterator, then calls next(iterator) repeatedly, receiving one item each time, until next raises StopIteration. That is the whole mechanism, and everything that supports for supports it by implementing those two functions.

Iterators and the iteration protocol: for x in things: → it = iter(things) → x = next(it) → run the block → loop ends
Iterators and the iteration protocol · Figure 1 · Open for full size

The two words matter. An iterable is anything iter() accepts: lists, strings, dictionaries, files, ranges. An iterator is what iter() returns: an object with a __next__ method that remembers its position. A list is iterable but not an iterator; calling iter(list) gives a fresh iterator each time, which is why you can loop over the same list twice. An iterator is consumed as it goes: once it has raised StopIteration, it is finished, and looping over it again yields nothing. Files, zip, map, enumerate, and generator expressions all return iterators, and that is the source of the "why is my second loop empty" bug.

Term Has Examples Loop twice?
iterable __iter__ returning an iterator list, str, dict, range, set yes
iterator __next__ (and __iter__ returning itself) file object, zip(...), map(...), generator no

To make your own class loopable, give it __iter__. The simplest version returns an iterator over some internal collection, return iter(self._items), and that is enough for most classes. To control the iteration yourself, write a separate iterator class with __next__ that tracks state and raises StopIteration when done, or, far more often, use a generator, which the next lesson introduces and which writes the iterator for you.

Iterators, and lazy sequences like range, produce one item when asked and no more. That is why range(10**9) is instant, why a file can be read line by line without loading it, and why zip and map cost nothing until you loop. Laziness also allows endless iterators, like itertools.count(), which would be impossible to build as a list. When you see a function that returns an iterator instead of a list, that is a deliberate choice: it lets the caller stop early and keeps memory flat.

Iterators and the iteration protocol: list(range(10**9)) → billions of ints in memory → range(10**9) → one number at a time, on demand → open(path) → one line at a time
Iterators and the iteration protocol · Figure 2 · Open for full size

Code

The protocol by hand. iter gives an iterator, next pulls items, and the exhausted iterator raises StopIteration, which for catches for you.

# protocol.py
colors = ["red", "green", "blue"]
it = iter(colors)

print(type(it).__name__)
print(next(it), next(it), next(it))
print(next(it, "no more"))
next(it)
Output
list_iterator
red green blue
no more
Traceback (most recent call last):
  File "protocol.py", line 7, in <module>
    next(it)
    ~~~~^^^^
StopIteration

Iterable versus iterator: a list loops twice, a zip iterator is consumed once and empty the second time. Check the kind with iter(x) is x.

# exhausted.py
pairs_list = list(zip("ab", [1, 2]))
pairs_iter = zip("ab", [1, 2])

print([p for p in pairs_list], [p for p in pairs_list])
print([p for p in pairs_iter], [p for p in pairs_iter])

for thing in (pairs_list, pairs_iter, "text", open(__file__)):
    it = iter(thing)
    print(f"{type(thing).__name__:<20} iterator itself? {it is thing}")
Output
[('a', 1), ('b', 2)] [('a', 1), ('b', 2)]
[('a', 1), ('b', 2)] []
list                 iterator itself? False
zip                  iterator itself? True
str                  iterator itself? False
TextIOWrapper        iterator itself? True

Your own iterable, two ways. Playlist delegates to its list; Countdown is a full iterator with __next__ and its own state.

# custom.py
class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __iter__(self):
        return iter(self._songs)


class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1


for song in Playlist(["intro", "verse"]):
    print(song)
print(list(Countdown(3)), list(Countdown(3)))
cd = Countdown(2)
print(list(cd), list(cd))
Output
intro
verse
[3, 2, 1] [3, 2, 1]
[2, 1] []

Laziness in action. The range and the file are never materialized, an endless counter is safe to create, and zip stops as soon as the shortest input does.

# lazy.py
import itertools
import sys
from pathlib import Path

big = range(10**9)
print(len(big), sys.getsizeof(big), "bytes")

Path("lines.txt").write_text("\n".join(f"line {i}" for i in range(100_000)), encoding="utf-8")
with open("lines.txt", encoding="utf-8") as f:
    for number, line in enumerate(f, start=1):
        if number == 3:
            print("stopped early at:", line.strip())
            break

endless = itertools.count(1)
print(list(itertools.islice(endless, 5)))
print(list(zip(endless, "abc")))
Output
1000000000 48 bytes
stopped early at: line 2
[1, 2, 3, 4, 5]
[(6, 'a'), (7, 'b'), (8, 'c')]

Try it yourself

Create iterate.py with a Fibonacci iterator class whose __next__ yields the next Fibonacci number forever, and use itertools.islice to print the first ten. Then write a Deck class that stores 52 card strings and supports for, len(), and in. Finally, create one map object, loop over it twice, and explain in a comment why the second loop printed nothing.

Common mistakes

What's next

You know what iteration is. Next you will write iterators the easy way: generators, with yield, and chain them into lazy pipelines.

Course overview