Skip to content
aviral gupta

// I3.1 · ~32 min · Intermediate

The iterator protocol

After this lesson you can step through any iterable by hand with iter() and next(), write a class that works in a for loop, and tell a reusable iterable from a one-pass iterator.

Lesson 1 of 6 in I3 Iteration and functional tools

Start of the module

You will be able to

  • Step through an iterable by hand with iter() and next(), and handle its end
  • Write an iterator class with __iter__ and __next__ that raises StopIteration
  • Tell an iterable, which gives a fresh iterator each time, from a one-pass iterator
  1. Warm-up · Activity 1 of 7

    Warm-up from module I2: an object keeps its state in attributes. What does this print?

    class Counter:
        def __init__(self, start):
            self.value = start
    
        def step(self):
            self.value -= 1
            return self.value
    
    c = Counter(3)
    print(c.step(), c.step())
  2. Predict · Activity 2 of 7

    Predict before you read on: what does this print?

    numbers = [1, 2, 3]
    it = iter(numbers)
    print(list(it), list(it))
  3. Practice · Activity 3 of 7

    Fill in the exception that tells a for loop, or list(), that the countdown is over.

    class Countdown:
        def __init__(self, start):
            self.current = start
    
        def __iter__(self):
            return self
    
        def __next__(self):
            if self.current <= 0:
                raise ____
            self.current -= 1
            return self.current + 1
    raise
  4. Practice · Activity 4 of 7

    next() can take a second argument. What does this print?

    it = iter("ab")
    print(next(it), next(it), next(it, "-"))
  5. Practice · Activity 5 of 7

    Match each part of the iterator protocol to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The same object is summed twice. What does this print?

    class Numbers:
        def __init__(self):
            self.n = 0
    
        def __iter__(self):
            return self
    
        def __next__(self):
            if self.n == 3:
                raise StopIteration
            self.n += 1
            return self.n
    
    nums = Numbers()
    print(sum(nums), sum(nums))
  7. Apply · Activity 7 of 7

    Mini-task. Write an iterator class Steps(start, stop, step) that works like range for a positive step: Steps(0, 10, 3) gives 0, 3, 6, 9. Give it __iter__ and __next__, and raise StopIteration once the value reaches stop. Print list(Steps(0, 10, 3)) and list(Steps(5, 5, 1)).

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A playlist you can loop over again and again

Playlist is an iterable: its __iter__ returns a new PlaylistIterator every time. The iterator keeps its own position in self.index. The program takes two songs by hand with next(), then loops over the playlist, which starts a second, independent iterator from the beginning. The first iterator is still where it stopped.

main.py

class PlaylistIterator:
    """Steps through a playlist's songs, one per next()."""

    def __init__(self, songs: list[str]) -> None:
        self.songs = songs
        self.index = 0

    def __iter__(self) -> "PlaylistIterator":
        return self

    def __next__(self) -> str:
        if self.index >= len(self.songs):
            raise StopIteration
        song = self.songs[self.index]
        self.index += 1
        return song


class Playlist:
    """An iterable: every loop gets a fresh iterator."""

    def __init__(self, *songs: str) -> None:
        self.songs = list(songs)

    def __iter__(self) -> PlaylistIterator:
        return PlaylistIterator(self.songs)


playlist = Playlist("Intro", "Verse", "Outro")
it = iter(playlist)
print(next(it))
print(next(it))
for song in playlist:
    print("loop:", song)
print(next(it))
print(next(it, "end of playlist"))

Run it with

python main.py

Output

Intro
Verse
loop: Intro
loop: Verse
loop: Outro
Outro
end of playlist
  • The for loop called iter(playlist) and got its own iterator, so it started again at Intro.
  • it was not disturbed by the loop: its next song was still Outro.
  • The last next(it, …) found it used up and returned the default instead of raising StopIteration.
  • PlaylistIterator returns self from __iter__, so it could be used in a for loop as well.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

A countdown iterator

Finish the iterator class Countdown in main.py. Countdown(3) gives 3, 2 and 1, one number per next(), and then raises StopIteration, again on every later call. Countdown(0) gives nothing. __iter__ already returns self; write __next__.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    First check whether anything is left: if self.current <= 0: raise StopIteration.

  2. Hint 2

    Keep the current number in a variable before you change self.current, so you can return it.

  3. Hint 3

    number = self.current; self.current -= 1; return number. Because self.current stays at 0, every later call raises again.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Countdown:
    """Counts down from start to 1, one number per next()."""

    def __init__(self, start: int) -> None:
        self.current = start

    def __iter__(self) -> "Countdown":
        return self

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


if __name__ == "__main__":
    print(list(Countdown(3)))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Countdown:
    """Counts down from start to 1, one number per next()."""

    def __init__(self, start: int) -> None:
        self.current = start

    def __iter__(self) -> "Countdown":
        return self

    def __next__(self) -> int:
        # Raise StopIteration when the count is used up;
        # otherwise return the current number and step down.
        raise StopIteration


if __name__ == "__main__":
    print(list(Countdown(3)))

test_main.py

from main import Countdown


def test_counts_down():
    """Countdown(3) gives 3, 2, 1"""
    got = list(Countdown(3))
    assert got == [3, 2, 1], f"list(Countdown(3)) returned {got!r}, expected [3, 2, 1]"


def test_next_by_hand():
    """next() gives one number per call"""
    c = Countdown(2)
    got = next(c), next(c)
    assert got == (2, 1), f"two next() calls returned {got!r}, expected (2, 1)"


def test_is_its_own_iterator():
    """iter() of a Countdown is the Countdown itself"""
    c = Countdown(2)
    assert iter(c) is c, "iter(c) did not return c itself: __iter__ must return self"


def test_stays_exhausted():
    """After the last number, next() keeps raising StopIteration"""
    c = Countdown(1)
    list(c)
    for _ in range(2):
        try:
            got = next(c)
        except StopIteration:
            continue
        raise AssertionError(f"next() on a used-up Countdown returned {got!r} instead of raising StopIteration")


def test_zero():
    """Countdown(0) gives nothing"""
    got = list(Countdown(0))
    assert got == [], f"list(Countdown(0)) returned {got!r}, expected []"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

A sentence you can loop over twice

The starter’s Sentence is its own iterator, so a second loop over the same sentence finds nothing. Make it a reusable iterable: __iter__ returns a fresh iterator over self.words each time, and the class needs no __next__ and no index. Then list(s) works twice, and two nested loops over s work too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    A list already knows how to hand out fresh iterators. Let the list do the work.

  2. Hint 2

    __iter__ can return iter(self.words). Delete __next__ and self.index.

  3. Hint 3

    For the annotation, import Iterator from collections.abc and write -> Iterator[str].

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from collections.abc import Iterator


class Sentence:
    """The words of a text; loop over it as often as you like."""

    def __init__(self, text: str) -> None:
        self.words = text.split()

    def __iter__(self) -> Iterator[str]:
        return iter(self.words)


if __name__ == "__main__":
    s = Sentence("to be or not")
    print(list(s))
    print(list(s))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Sentence:
    """The words of a text; loop over it as often as you like."""

    def __init__(self, text: str) -> None:
        self.words = text.split()
        self.index = 0

    def __iter__(self) -> "Sentence":
        return self

    def __next__(self) -> str:
        if self.index >= len(self.words):
            raise StopIteration
        word = self.words[self.index]
        self.index += 1
        return word


if __name__ == "__main__":
    s = Sentence("to be or not")
    print(list(s))
    print(list(s))

test_main.py

from main import Sentence


def test_words():
    """A Sentence gives its words in order"""
    got = list(Sentence("to be or not"))
    assert got == ["to", "be", "or", "not"], f"list(Sentence('to be or not')) returned {got!r}"


def test_two_loops():
    """A second loop gives all the words again"""
    s = Sentence("to be or not")
    first, second = list(s), list(s)
    assert first == second == ["to", "be", "or", "not"], f"two list(s) calls returned {first!r} and {second!r}"


def test_fresh_iterator():
    """iter(s) returns a new iterator, not s itself"""
    s = Sentence("a b")
    assert iter(s) is not s, "iter(s) returned s itself: a Sentence should hand out a fresh iterator"


def test_nested_loops():
    """Two loops over the same Sentence can run at the same time"""
    s = Sentence("x y")
    got = [a + b for a in s for b in s]
    assert got == ["xx", "xy", "yx", "yy"], f"the nested loops gave {got!r}, expected ['xx', 'xy', 'yx', 'yy']"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

Take the first n items

Write take(iterable, n), which returns a list of the first n items, or fewer if the iterable runs out. It must work with any iterable, including an iterator, and must not take more than n items from it: after take(it, 2), next(it) still gives the third item. Use iter() and next(), and catch StopIteration.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    list(iterable) reads the whole iterable, which uses up an iterator completely. Take one item at a time instead.

  2. Hint 2

    Get an iterator with it = iter(iterable), then call next(it) at most n times in a for _ in range(n): loop.

  3. Hint 3

    Wrap result.append(next(it)) in try, and break in except StopIteration:.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from collections.abc import Iterable


def take(iterable: Iterable[str], n: int) -> list[str]:
    """Return the first n items of iterable, or fewer if it runs out."""
    it = iter(iterable)
    result: list[str] = []
    for _ in range(n):
        try:
            result.append(next(it))
        except StopIteration:
            break
    return result


if __name__ == "__main__":
    print(take(["a", "b", "c"], 2))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from collections.abc import Iterable


def take(iterable: Iterable[str], n: int) -> list[str]:
    """Return the first n items of iterable, or fewer if it runs out."""
    return list(iterable)[:n]


if __name__ == "__main__":
    print(take(["a", "b", "c"], 2))

test_main.py

from main import take


def test_first_two():
    """take(['a', 'b', 'c'], 2) is ['a', 'b']"""
    got = take(["a", "b", "c"], 2)
    assert got == ["a", "b"], f"take(['a', 'b', 'c'], 2) returned {got!r}"


def test_runs_out():
    """Asking for more items than there are gives them all"""
    got = take("xyz", 5)
    assert got == ["x", "y", "z"], f"take('xyz', 5) returned {got!r}, expected ['x', 'y', 'z']"


def test_leaves_the_rest():
    """take(it, 2) leaves the third item in the iterator"""
    it = iter(["a", "b", "c", "d"])
    take(it, 2)
    got = next(it, None)
    assert got == "c", f"after take(it, 2), next(it) gave {got!r}, expected 'c': take used up too many items"


def test_zero():
    """take(..., 0) is an empty list"""
    got = take(["a"], 0)
    assert got == [], f"take(['a'], 0) returned {got!r}, expected []"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Calling next() on a list

numbers = [3, 1, 2]
print(next(numbers))

What Python prints

TypeError: 'list' object is not an iterator

Why, and the fix

A list is iterable, but it is not an iterator: it has no __next__. Ask it for an iterator first, it = iter(numbers), and call next(it). A for loop does this step for you.

An iterator class without __iter__

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

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


for n in Countdown(3):
    print(n)

What Python prints

TypeError: 'Countdown' object is not iterable

Why, and the fix

for starts by calling iter() on the object, and iter() looks for __iter__. Add def __iter__(self): return self. With both methods the class follows the whole protocol, and list(), sum() and for accept it.

next() past the end without a default

it = iter(["a"])
print(next(it))
print(next(it))

What Python prints

StopIteration

Why, and the fix

The second next() finds the iterator used up and raises StopIteration, which only a loop catches for you. Pass a default, next(it, None), or catch the exception with try and except StopIteration.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

What a for loop does

for item in container: first calls iter(container), which returns an iterator. Then it calls next() on that iterator again and again, and each call returns the next item. When there are no more items, the iterator raises StopIteration, and the for loop ends quietly. You can do the same by hand: it = iter([10, 20]); next(it) gives 10, then 20, then raises StopIteration. next(it, default) returns default instead of raising, which is handy when you want only the first item, or a fallback when there is none.

Writing your own iterator

An iterator is an object with two methods. __next__ returns the next item, or raises StopIteration when it is used up. __iter__ returns the iterator itself: return self. That second method looks pointless, but it lets for and every function that takes an iterable, such as list, sum and sorted, accept the iterator too. The iterator keeps its position in attributes such as self.index, which __next__ reads and moves on. Once it has raised StopIteration, it should keep raising it on every later call.

Iterables and iterators

An iterable is anything iter() accepts: lists, strings, dicts, files, ranges, and your classes with __iter__. A container such as a list hands out a fresh iterator every time, so you can loop over it twice, or nest two loops over it. An iterator is good for one pass: iter() on it returns the same object, and once it is exhausted, a second loop finds nothing, with no error at all. To let your own class loop more than once, give it an __iter__ that returns a new iterator, such as iter(self.items), rather than self.

Sources

Last reviewed September 29, 2026