Learning Goals
3 minBy the end of this lesson you can:
- Trace what Python does internally when running
for x in seq:. - Define a class with
__iter__and__next__to make your own iterator. - Use
iter(obj)andnext(it)manually. - Recognise that iterators are one-shot — exhausted after one pass.
Warm-Up · What a for-loop Actually Does
5 min# This loop: for x in [10, 20, 30]: print(x) # Is roughly equivalent to: it = iter([10, 20, 30]) # __iter__ while True: try: x = next(it) # __next__ except StopIteration: break print(x)
Two dunders, one exception. iter() turns something iterable into an iterator. next() gets the next value. StopIteration ends the loop.
For-loops aren't magic. They're a tiny while-loop wrapped around iter() and next(). Knowing this lets you build your own iterables and understand laziness deeply.
New Concept · The Iterator Protocol
14 minIterable vs iterator — two roles
Iterable something you CAN iterate (list, str, set, dict, file, range...)
Defines __iter__ — "give me a fresh iterator"
Iterator the THING THAT REMEMBERS WHERE YOU ARE
Defines __next__ — "give me the next value or StopIteration"
Also defines __iter__ that returns itselfA list is an iterable. Calling iter([1,2,3]) gives you a list iterator. Two different objects.
Two manual calls
it = iter([10, 20, 30]) print(type(it)) # <class 'list_iterator'> print(next(it)) # 10 print(next(it)) # 20 print(next(it)) # 30 print(next(it)) # StopIteration!
You never normally write next() by hand — for-loops do it for you. But seeing it explicit reveals what's happening.
Build your own — a Countdown class
class Countdown: """Yields n, n-1, ..., 1, then stops.""" def __init__(self, n): self.n = n def __iter__(self): return self # an iterator returns itself from __iter__ def __next__(self): if self.n <= 0: raise StopIteration value = self.n self.n -= 1 return value for x in Countdown(5): print(x) # 5 # 4 # 3 # 2 # 1
Three pieces: __init__ sets up state, __iter__ returns self, __next__ returns the next value or raises StopIteration. The for-loop wires them together.
Iterators are one-shot
cd = Countdown(3) for x in cd: print(x) # 3, 2, 1 print("Again?") for x in cd: print(x) # (nothing! n is already 0)
Once an iterator is exhausted, it's done. If you need a re-iterable, separate the iterable from the iterator — define __iter__ to return a new helper iterator each time:
class Numbers: """Iterable — re-iterable.""" def __init__(self, n): self.n = n def __iter__(self): return NumbersIter(self.n) # fresh iterator each time class NumbersIter: """One-shot iterator.""" def __init__(self, n): self.n = n self.i = 0 def __iter__(self): return self def __next__(self): if self.i >= self.n: raise StopIteration self.i += 1 return self.i nums = Numbers(3) print(list(nums)) # [1, 2, 3] print(list(nums)) # [1, 2, 3] -- works again!
That's why list, str, etc. work twice in a row — they're iterables, each iter() call creates a fresh iterator. map and filter are iterators themselves — that's why they exhaust.
Or just use a generator (tomorrow)
The class-based approach above is verbose. Tomorrow's lesson — yield — makes the same thing one function. But the class form is essential reading because it makes the protocol concrete.
Worked Example · The Fibonacci Iterator
12 minSave as fib_iter.py:
# fib_iter.py — finite Fibonacci as an iterator class class FibUpTo: """Yields Fibonacci numbers up to (but not exceeding) limit.""" def __init__(self, limit): self.limit = limit self.a = 0 self.b = 1 def __iter__(self): return self # I am an iterator def __next__(self): if self.a > self.limit: raise StopIteration value = self.a self.a, self.b = self.b, self.a + self.b # advance return value # Use in a for-loop for n in FibUpTo(100): print(n, end=" ") # 0 1 1 2 3 5 8 13 21 34 55 89 # Or use with list() print() print(list(FibUpTo(50))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] # Or with sum print(sum(FibUpTo(100))) # 232
Read the diff
__next__ uses tuple swap (self.a, self.b = self.b, self.a + self.b) to advance both numbers in one line — exactly the swap idiom from PY-L2-04. The for-loop, list(), and sum() all work because they only ask __iter__ and __next__. None of them care that FibUpTo isn't a list.
An iterator computes values one at a time. The Fibonacci numbers up to 100 take 12 ints of memory. A list of the first million Fibonacci numbers would take megabytes — but an iterator could yield them on demand and never store more than two at once.
Try It Yourself
13 minBuild a Squares class that yields 1², 2², ..., n². For-loop it; print each value.
Hint
class Squares: def __init__(self, n): self.n = n self.i = 0 def __iter__(self): return self def __next__(self): if self.i >= self.n: raise StopIteration self.i += 1 return self.i * self.i for s in Squares(5): print(s) # 1, 4, 9, 16, 25
Build a list iterator manually. Call next() a couple of times explicitly, then run a for-loop to consume the rest. Confirm the for-loop picks up after your manual next() calls.
Hint
it = iter([10, 20, 30, 40, 50]) print(next(it)) # 10 print(next(it)) # 20 # The for-loop picks up from here for x in it: print(x) # 30, 40, 50
The iterator remembers its position. Manual next() consumes from the front; the for-loop continues from there.
Build Counter(n) that's a re-iterable — every for-loop on it starts from 1.
Hint
class Counter: def __init__(self, n): self.n = n def __iter__(self): return _CounterIter(self.n) # fresh iterator each time class _CounterIter: def __init__(self, n): self.n = n self.i = 0 def __iter__(self): return self def __next__(self): if self.i >= self.n: raise StopIteration self.i += 1 return self.i c = Counter(3) print(list(c)) # [1, 2, 3] print(list(c)) # [1, 2, 3] -- works again for x in c: # works AGAIN print(x)
Separating iterable from iterator is the trick for re-iteration. Lists do this for you; custom iterables need both classes.
Mini-Challenge · The Word Stream
8 minBuild a WordStream iterator class that takes a string of text and yields one cleaned word at a time. Cleaned = lower-cased and stripped of punctuation.
Test:
text = "Hello, world! Welcome — to the iterator demo." for w in WordStream(text): print(w) # hello, world, welcome, to, the, iterator, demo
Show one possible solution
import re class WordStream: PAT = re.compile(r"[A-Za-z']+") def __init__(self, text): self._matches = self.PAT.finditer(text) def __iter__(self): return self def __next__(self): m = next(self._matches) # propagates StopIteration when done return m.group().lower() text = "Hello, world! Welcome — to the iterator demo." for w in WordStream(text): print(w)
Non-negotiables: __iter__ returns self, __next__ yields one word. We delegate to re.finditer — itself an iterator. When it's exhausted, the next() call inside __next__ raises StopIteration, which propagates out. That's "chaining iterators".
Recap
3 minEvery for-loop calls iter() to get an iterator, then next() until StopIteration. An iterable defines __iter__ that returns a fresh iterator. An iterator defines __next__ that yields one value or raises StopIteration. Iterators are one-shot — they exhaust. For re-iterability, separate the iterable from the iterator. The class-based protocol works but is verbose; tomorrow's yield makes the same shape five times shorter.
Vocabulary Card
- iterable
- Has
__iter__. Can be looped.list,str,dict,range, your own classes. - iterator
- Has both
__iter__and__next__. Remembers where it is. One-shot. - StopIteration
- The exception
__next__raises when there are no more values. - iterator protocol
- The contract:
iter()+next()+StopIteration. Powers every for-loop.
Homework
4 minBuild even_iter.py. Define EvenNumbers(max_n) that yields 2, 4, 6, ... up to and including max_n. Test:
- Loop it; print each even.
- Pass to
list(); print the resulting list. - Pass to
sum(); print the sum. - Pass to
max(); print the highest even.
Stretch. Make it a re-iterable so all four tests work in sequence on the same instance.
Sample · re-iterable EvenNumbers
class EvenNumbers: """Re-iterable. Each iter() call returns a fresh iterator.""" def __init__(self, max_n): self.max_n = max_n def __iter__(self): return _EvenIter(self.max_n) class _EvenIter: def __init__(self, max_n): self.max_n = max_n self.current = 0 def __iter__(self): return self def __next__(self): self.current += 2 if self.current > self.max_n: raise StopIteration return self.current evens = EvenNumbers(10) print(list(evens)) # [2, 4, 6, 8, 10] print(sum(evens)) # 30 -- works again! print(max(evens)) # 10 -- works yet again! for n in evens: print(n) # full loop works
Non-negotiables: two classes, the outer one re-iterable, the inner one one-shot. EvenNumbers can be reused freely; each pass creates a fresh _EvenIter.