Iterators & the Iterator Protocol Interview Questions & Answers

6 questions Updated 2026-06-18

Python interview questions on iterables vs iterators, the iterator protocol, __iter__ and __next__, StopIteration, building custom iterators, and how for loops work under the hood.

An iterable is anything you can loop over — it knows how to produce an iterator via __iter__. An iterator is the object that actually does the walking: it has __next__ and yields one value at a time, remembering its position. Every iterator is iterable (its __iter__ returns itself), but not every iterable is an iterator.

nums = [1, 2, 3]          # list: iterable, NOT an iterator
it = iter(nums)           # iterator over the list
next(it)                  # 1  — iterators track position
next(it)                  # 2

Think of the iterable as the collection and the iterator as a cursor/bookmark into it. You can create many independent iterators from one iterable.

The iterator protocol is two methods. __iter__ must return the iterator object itself, and __next__ returns the next value or raises StopIteration when exhausted. That exception is the agreed signal that there are no more items.

it = iter([10, 20])
it.__next__()      # 10
it.__next__()      # 20
it.__next__()      # raises StopIteration

An iterable only needs __iter__ (returning a fresh iterator). An iterator needs both. The StopIteration raise is what lets for loops know when to stop — they catch it silently.

iter(obj) calls obj.__iter__() to get an iterator; next(it) calls it.__next__() to advance it. next() accepts an optional default that is returned instead of raising StopIteration when the iterator is exhausted — handy for safe peeking.

it = iter("ab")
next(it)            # 'a'
next(it)            # 'b'
next(it, "done")    # 'done'  — default instead of StopIteration

# iter() also has a two-arg sentinel form:
# iter(callable, sentinel) calls until it returns sentinel

Use the default argument whenever you want to drain or sample an iterator without wrapping next() in a try/except StopIteration.

Implement __iter__ (return self) and __next__ (return the next value or raise StopIteration). The instance holds its own state between calls.

class Countdown:
    def __init__(self, start):
        self.n = start
    def __iter__(self):
        return self
    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

list(Countdown(3))     # [3, 2, 1]

This works, but for most cases a generator function (using yield) is far less boilerplate — it builds the __iter__/__next__/StopIteration machinery for you. Reach for a class only when you need extra methods or explicit state.

A for loop is sugar over the iterator protocol. Python calls iter() on the iterable once to get an iterator, then repeatedly calls next() on it, binding each result to the loop variable, until StopIteration is raised — which it catches to end the loop.

for x in [1, 2, 3]:
    print(x)

# is roughly equivalent to:
_it = iter([1, 2, 3])
while True:
    try:
        x = next(_it)
    except StopIteration:
        break
    print(x)

This is why any object implementing the protocol "just works" in a for loop, comprehension, or *-unpacking. The StopIteration is the hidden handshake that terminates the loop.

An iterator is single-use / exhaustible: once __next__ has walked to the end and raised StopIteration, it stays exhausted — there is no reset. Re-iterating yields nothing. This trips people up with generators and zip/map objects.

it = iter([1, 2, 3])
list(it)     # [1, 2, 3]
list(it)     # []  — already exhausted!

gen = (x for x in range(3))
sum(gen)     # 3
sum(gen)     # 0  — the generator is spent

A list (an iterable, not an iterator) can be looped many times because each loop calls iter() to get a fresh iterator. If you need to reuse an exhaustible result, materialize it into a list first.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.