Skip to content

Iterators & the Iterator Protocol Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

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.

Read the in-depth guidePython Iterators and the Iterator Protocol Explained — iter, next, and for Loops(opens in new tab)
15 of 15

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.

iter(func, sentinel) builds an iterator that calls func() repeatedly until it returns the sentinel value, then stops. It's perfect for reading streams in fixed-size chunks without a while True/break.

# read a file in 1024-byte blocks until EOF (b''):
with open("data.bin", "rb") as f:
    for chunk in iter(lambda: f.read(1024), b""):
        process(chunk)

# roll a die until you get a 6:
import random
list(iter(lambda: random.randint(1, 6), 6))

Rule of thumb: use iter(callable, sentinel) to turn a "call until you see X" loop into a clean iterator, especially for chunked I/O.

Make __iter__ return a fresh iterator each call (a generator or a new iterator object) instead of self. Then the class is a reusable iterable, not a single-use iterator.

class Squares:
    def __init__(self, n): self.n = n
    def __iter__(self):
        return (i * i for i in range(self.n))   # new generator each time

sq = Squares(3)
list(sq)     # [0, 1, 4]
list(sq)     # [0, 1, 4] — works again!

Rule of thumb: return self from __iter__ for one-shot iterators; return a new generator/iterator for collections meant to be looped repeatedly.

Regular slicing (it[1:3]) doesn't work on iterators. Use itertools.islice, which lazily takes a range of items — essential for infinite or huge iterators.

from itertools import islice, count

islice(count(), 2, 5)            # lazy: 2, 3, 4
list(islice(count(), 2, 5))      # [2, 3, 4]
list(islice(range(100), 10))     # first 10

Rule of thumb: islice is the iterator-friendly slice; it consumes (and discards) skipped items and never materializes the whole sequence.

tee(iterable, n) returns n independent iterators over the same source. It buffers items already consumed by one branch until the others catch up — useful when you can't re-create the source.

from itertools import tee

it = (x * x for x in range(5))
a, b = tee(it, 2)
list(a)     # [0, 1, 4, 9, 16]
list(b)     # [0, 1, 4, 9, 16] — independent copy

# warning: don't keep using the original `it` after tee-ing it

Rule of thumb: use tee for limited multi-pass over a one-shot iterator, but if the branches diverge a lot it buffers heavily — then a list is simpler.

Iterators have no built-in peek. Pull the value with next(), then push it back by chaining it in front with itertools.chain — a common "lookahead" pattern.

from itertools import chain

it = iter([1, 2, 3])
first = next(it)              # 1 — consumed
it = chain([first], it)       # put it back on the front
list(it)                      # [1, 2, 3] — nothing lost

Rule of thumb: emulate peeking by next() + chain([val], it); for repeated lookahead, wrap the iterator in a small buffering class.

You can't test emptiness without consuming an item. Use next(it, sentinel) and compare against a unique sentinel; if you need the item, re-attach it with chain.

from itertools import chain
_sentinel = object()

def is_empty(it):
    first = next(it, _sentinel)
    if first is _sentinel:
        return True, it
    return False, chain([first], it)   # give the item back

empty, it = is_empty(iter([]))     # (True, ...)

Rule of thumb: there's no peek-free emptiness check — pull one item with a sentinel default and rebuild the iterator if it wasn't empty.

reversed(seq) returns an iterator that walks a sequence backwards. It needs a sequence that supports __reversed__ or both __len__ and __getitem__ — so it works on lists/tuples/ranges but not on plain generators or sets.

list(reversed([1, 2, 3]))     # [3, 2, 1]
list(reversed(range(3)))      # [2, 1, 0] — ranges are sized
reversed(x for x in range(3)) # TypeError — generators aren't reversible

class C:
    def __reversed__(self): return iter([9, 8, 7])
list(reversed(C()))           # [9, 8, 7] — custom hook

Rule of thumb: reversed needs a sized, indexable sequence (or __reversed__); for one-shot iterables, materialize to a list first.

Since PEP 479 (default in 3.7+), a StopIteration that bubbles out of a generator body is converted into a RuntimeError. This prevents a bug where an inner next() raising StopIteration would silently end the generator.

def gen(it):
    while True:
        yield next(it)      # when `it` is exhausted, next() raises StopIteration

list(gen(iter([1, 2])))     # RuntimeError: generator raised StopIteration

def safe(it):
    for x in it:            # use a for-loop, which handles StopIteration
        yield x

Rule of thumb: inside generators, consume sub-iterators with for/yield from (or next(it, default)), never a bare next() that can raise StopIteration.

Yes — in Python 3 map, filter, and zip return lazy iterators, not lists. They compute on demand and are single-pass, so you must wrap them in list() to see or reuse all results.

m = map(str.upper, ["a", "b"])
next(m)        # 'A' — lazy, one at a time
list(m)        # ['B'] — 'A' already consumed

list(filter(lambda x: x > 0, [-1, 2, -3, 4]))   # [2, 4]

Rule of thumb: map/filter/zip are one-shot iterators in Python 3 — materialize with list() if you need indexing, length, or multiple passes.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel