Intuition What this page is
> The parent note taught the iterator protocol : iter() gets the tape player, next() presses the button, StopIteration is the "tape ended" click. This page is the firing range . We take every kind of situation the protocol can throw at you — finite, infinite, empty, reusable-vs-consumed, manual driving, the exam trick — and we walk each one from zero. By the end, no scenario should surprise you.
Every problem about iterators falls into one of these cells. Each row is a "case class"; the last column names the worked example that covers it.
Cell
Situation
The tricky part
Covered by
A
Finite iterator, counts down
exhaustion check ordering
Ex 1
B
Finite iterator, counts up to a bound
when to raise vs return
Ex 2
C
Empty input (degenerate)
StopIteration on the first press
Ex 3
D
Infinite iterator
never raising; how to stop it safely
Ex 4
E
Consumed-once bug (iterator IS itself)
state not resetting
Ex 5
F
Reusable iterable (fresh iterator each loop)
__iter__ returns a new object
Ex 5
G
Manual driving with next(it, default)
default suppresses the raise
Ex 6
H
Nested loops over one list
two independent positions
Ex 7
I
Real-world word problem (streaming / lazy)
memory, one item at a time
Ex 8
J
Exam twist: StopIteration inside a generator (PEP 479)
why it becomes RuntimeError
Ex 9
We now clear the whole board, cell by cell.
Countdown(4) — trace every press
Build an iterator so Countdown(4) yields 4, 3, 2, 1 and then stops. List the exact sequence of return values and the moment StopIteration fires.
Forecast: How many times does the button get pressed in total , counting the final press that raises? Guess before reading.
class Countdown :
def __init__ (self, start):
self .current = start
def __iter__ (self):
return self
def __next__ (self):
if self .current <= 0 : # step 1
raise StopIteration # step 2
value = self .current # step 3
self .current -= 1 # step 4
return value # step 5
Check exhaustion first (self.current <= 0). Why this step? If we returned first and checked later, we'd hand back 0. The check must gate the return.
Raise the signal. Why? That is the only legal way an iterator says "done" — the for loop is listening for exactly this.
Save the value before touching state. Why? We must return the current number, not the already-decremented one.
Advance the state. Why? So the next press sees a smaller number.
Return. Why? Hand the item to whoever pressed the button.
Trace of presses on Countdown(4):
press #
current in
returns
current out
1
4
4
3
2
3
3
2
3
2
2
1
4
1
1
0
5
0
— raises StopIteration
0
Verify: list(Countdown(4)) == [4, 3, 2, 1]. The sum of the four values is 4 + 3 + 2 + 1 = 10 . Total button presses = 5 (four items + one final raise) — matching the forecast: to loop over n items the button is pressed n + 1 times.
UpTo(3) — where does the boundary go?
Build UpTo(3) to yield 0, 1, 2 (stop before 3). This is the classic off-by-one trap.
Forecast: Should the stop test be self.n >= self.limit or self.n > self.limit? Pick one first.
class UpTo :
def __init__ (self, limit):
self .limit = limit
self .n = 0
def __iter__ (self):
return self
def __next__ (self):
if self .n >= self .limit: # step 1
raise StopIteration
value = self .n # step 2
self .n += 1
return value
Stop when n >= limit. Why >= not >? We want 0,1,2 for limit=3. When n reaches 3 we must stop without returning it. >= catches n == 3; > would let 3 slip out.
Return then advance , same discipline as Ex 1.
Verify: list(UpTo(3)) == [0, 1, 2], which has length 3 and sum 0 + 1 + 2 = 3 . This is exactly Python's built-in range(3) behaviour — half-open on the right.
Countdown(0) and UpTo(0) — zero input
What happens if you feed a count of zero? A robust iterator must survive the "nothing to give" case.
Forecast: How many times is the button pressed before StopIteration? Guess a number.
Trace Countdown(0):
First press → self.current is 0 → 0 <= 0 is True → raise StopIteration immediately. Why does this matter? The for loop catches it and runs the body zero times . No crash, no stray 0 returned.
Same for UpTo(0): self.n == 0, 0 >= 0 is True, raise on the first press.
Verify: list(Countdown(0)) == [] and list(UpTo(0)) == []. Both have length 0 . The button is pressed exactly 1 time (the raise), matching the n + 1 = 0 + 1 = 1 rule from Ex 1. This is why the exhaustion check must come first : it makes the empty case free.
Common mistake Putting the check
after the return
If you wrote value = self.current; self.current -= 1; if self.current < 0: raise, then Countdown(0) would hand back 0 before ever checking. The empty case is the litmus test that your guard is in the right place.
Evens() — infinite, and how to take only some
An iterator of all even numbers 0, 2, 4, 6, … never raises StopIteration. Take just the first five.
Forecast: What is the fifth even number produced if we start at 0? Guess.
class Evens :
def __init__ (self):
self .n = - 2
def __iter__ (self):
return self
def __next__ (self):
self .n += 2 # advances forever, never raises
return self .n
No exhaustion check exists. Why? There is no last even number, so there is no place to raise. This is legal and useful — but list(Evens()) would hang forever.
Bound it from the outside using `itertools.islice` :
from itertools import islice
first5 = list (islice(Evens(), 5 )) # [0, 2, 4, 6, 8]
Why islice and not slicing Evens()[:5]? Iterators have no __getitem__ ; you cannot index or slice them. islice presses next exactly 5 times and then stops asking — the infinite source never has to end.
Verify: first5 == [0, 2, 4, 6, 8]. The fifth value (index 4) is 2 × 4 = 8 , matching the forecast. Sum 0 + 2 + 4 + 6 + 8 = 20 . The infinite source produced only 5 items because the consumer controlled the count — the essence of lazy evaluation .
Worked example Why the second loop is empty — and the fix
Loop over the same Countdown object twice. Predict what the second loop prints.
Forecast: Does the second for print 3, 2, 1 again, or nothing?
c = Countdown( 3 )
print ( list (c)) # loop 1
print ( list (c)) # loop 2
Loop 1 consumes the state. Each press decrements self.current from 3 down to 0. After loop 1, self.current == 0.
Loop 2 calls iter(c) → returns self (the same , now-exhausted object). Why empty? Its first press sees 0 <= 0 and raises immediately — exactly the empty case of Ex 3.
So loop 1 gives [3, 2, 1], loop 2 gives []. This is Cell E — the consumed-once bug.
The Cell F fix — a reusable iterable that hands out a fresh tape player:
class CountdownReusable :
def __init__ (self, start):
self .start = start # store, never mutate
def __iter__ (self):
return Countdown( self .start) # NEW iterator each loop
__iter__ returns a brand-new Countdown. Why? The reusable object keeps its own start untouched; every loop gets a private counter. Now both loops print 3, 2, 1.
Verify: With r = CountdownReusable(3): list(r) == [3, 2, 1] and list(r) == [3, 2, 1]. The consumed version gives [3,2,1] then []. Both facts confirmed in the checks.
next(it, default) — one item, no try/except
You have it = iter([10, 20]). Press the button three times, the third time with a fallback value 'done'.
Forecast: What does the third press return — a crash, or 'done'?
it = iter ([ 10 , 20 ])
a = next (it) # 10
b = next (it) # 20
c = next (it, 'done' ) # 'done'
First two presses return 10 and 20 — normal button presses. Why do they work? The list's iterator still has items.
Third press would raise StopIteration (tape ended), but the second argument is a sentinel . Why supply it? next(it, default) catches the raise for you and returns default instead — a clean way to peek one item without wrapping code in try/except.
Verify: a == 10, b == 20, c == 'done'. And next(iter([]), 'x') == 'x' — the default rescues even the empty iterator on its very first press.
Worked example The 2×2 grid from one list
Nested-loop over nums = [1, 2]. Predict all printed pairs.
Forecast: Do you get 4 pairs, or does the inner loop "eat" the list so the outer loop stops early?
nums = [ 1 , 2 ]
pairs = []
for a in nums: # iterator A from iter(nums)
for b in nums: # iterator B — a DIFFERENT iterator
pairs.append((a, b))
Outer for calls iter(nums) → iterator A. A list is iterable but not its own iterator, so this is a fresh player with its own position.
Inner for calls iter(nums) again → iterator B , a separate player. Why does this matter? B's position is independent of A's, so exhausting B does not touch A.
Result: A yields 1, B yields 1,2; A yields 2, B yields 1,2.
Verify: pairs == [(1,1), (1,2), (2,1), (2,2)] — length 4 . Had a list been its own iterator (like our Countdown), the inner loop would drain the single shared position and the outer loop would never advance past 1. This is the entire reason iterable and iterator are separate roles.
Worked example Reading the first over-budget transaction from a huge log
A file transactions.log has 10 million lines, each an integer amount. You need the first amount above 1000 — and you must not load the whole file into memory.
Forecast: Roughly how many lines do we read if the answer is on line 4? Guess before reading.
def big_amounts (lines, threshold):
it = iter (lines) # one tape player over the file
while True :
try :
amt = int ( next (it)) # press once: read ONE line
except StopIteration :
return None # ran off the end, nothing found
if amt > threshold:
return amt # stop the moment we find it
Say the file's amounts begin 50, 200, 999, 1500, ….
iter(lines) gives one iterator over the file object. Why lazy? A file is an iterator: each next reads exactly one line from disk, never the whole file. Memory stays tiny regardless of file size.
Press until the condition hits. 50 → no, 200 → no, 999 → no, 1500 → yes, return it. Why stop? We answer the question at the earliest possible moment and never read line 5 onward.
StopIteration handled → if no line qualifies, return None rather than crash.
Verify: For amounts [50, 200, 999, 1500, 7], the function returns 1500 after reading exactly 4 lines (matching the forecast), touching none of the 10 million others. For [10, 20] it returns None. This is lazy evaluation paying rent: constant memory, minimal work.
raise StopIteration inside a generator explodes
An exam gives you this generator and asks: what happens when you loop over gen()?
Forecast: Clean stop, silent bug, or a RuntimeError?
def gen ():
yield 1
yield 2
raise StopIteration # WRONG inside a generator
list (gen())
yield 1, yield 2 produce two items normally. Why? Generators auto-implement __next__; each yield is one button press.
The explicit raise StopIteration does not politely end the generator. Since PEP 479 , a StopIteration that escapes a generator body is converted into a RuntimeError . Why this rule? Otherwise a stray StopIteration (e.g. from a nested next() deep inside) could silently truncate a loop, hiding bugs. PEP 479 makes that mistake loud.
The correct way to end a generator is return (bare, no value). In a hand-written iterator class , by contrast, you must raise StopIteration. Two mechanisms, two contexts — do not mix them.
Verify: list(gen()) raises RuntimeError (not StopIteration). Replacing the last line with a plain return makes list(gen()) == [1, 2]. Both behaviours are checked below via a simulated exhaustion helper.
Recall One-line summary of the whole board
Finite → check-then-return; empty → check fires first press; infinite → never raise, bound with islice; consumed-once → return a fresh iterator from __iter__; nested loops → each iter() is independent; generators → return, never raise.
Exhaustion count fact Looping over n items presses the button n + 1 times (the extra press raises StopIteration).
Why is list(Evens()) dangerous It never raises StopIteration, so the list keeps growing forever — bound it with islice.
Why does the second loop over a self-iterator give [] The object is its own iterator; its state was consumed and never reset.
What ends a generator vs a hand-written iterator Generator uses return; hand-written __next__ uses raise StopIteration.
Iterators — `__iter__`, `__next__`, StopIteration — the parent protocol these examples exercise.
itertools — islice, count, chain — the safe way to bound infinite iterators (Ex 4).
Generators — yield, lazy evaluation — Ex 9's context; return vs raise.
for loops and comprehensions — every example is really iter + next + catch.
Lazy evaluation and memory efficiency — the WHY behind Ex 4 and Ex 8.
StopIteration and PEP 479 — the rule Ex 9 tests.
Dunder (magic) methods — __iter__/__next__ live here.