Generators, iterators, and the iterator protocol
What actually makes a for loop work on a list, a file, and a generator alike — and how yield turns an ordinary function into a resumable one.
3 min read
The protocol underneath every for loop
for x in something: works on lists, strings, dicts, files, and generators alike because Python doesn't special-case any of them — it asks something for an iterator, then repeatedly asks that iterator for the next value, until it signals there are none left:
it = iter([10, 20, 30]) # __iter__() — get an iterator
next(it) # 10 — __next__()
next(it) # 20
next(it) # 30
next(it) # raises StopIteration — the loop ends hereAn iterable is anything with __iter__() (a list, a string, a custom class). An iterator is anything with __next__() (and its own __iter__() that returns itself). A for loop is exactly the code above, done automatically: call iter() once, call next() repeatedly, stop when StopIteration is raised.
Writing your own — the hard way
class CountUpTo:
def __init__(self, limit):
self.limit = limit
self.n = 0
def __iter__(self):
return self
def __next__(self):
if self.n >= self.limit:
raise StopIteration
self.n += 1
return self.n
for x in CountUpTo(3):
print(x) # 1, 2, 3This works, but it's a lot of ceremony — manually tracking state (self.n) across calls, manually raising StopIteration — for something conceptually simple: "count up to a limit."
yield — the same thing, without the ceremony
def count_up_to(limit):
n = 0
while n < limit:
n += 1
yield n
for x in count_up_to(3):
print(x) # 1, 2, 3Calling count_up_to(3) doesn't run the function body at all — it returns a generator object immediately. Each call to next() on it runs the function until the next yield, returns that value, and then pauses — with every local variable (n, in this case) frozen exactly where it was. The next next() call resumes right after that yield, not from the top. This is what a generator function actually is: ordinary function syntax that the interpreter turns into an object implementing the iterator protocol automatically, so you never write __iter__/__next__/StopIteration by hand.
Why this is the memory-efficient choice
def all_squares_list(n): # builds the entire list in memory before returning
return [i * i for i in range(n)]
def all_squares_gen(n): # produces one value at a time, on demand
for i in range(n):
yield i * iall_squares_list(10_000_000) allocates memory for ten million integers before you use a single one of them. all_squares_gen(10_000_000) holds only the current value and the loop's position — a handful of bytes, regardless of how large n is. If the caller is going to consume the sequence once, in order (a for loop, sum(), any()), a generator does the identical job for a fraction of the memory — this is the mechanism behind the list-vs-generator trade-off covered in the list-mechanics lesson.
The one real limitation
gen = count_up_to(3)
list(gen) # [1, 2, 3]
list(gen) # [] — already exhausted, nothing left to produceA generator can only be iterated once. Once it's exhausted (or you stop pulling from it partway through), there's no way to "rewind" it — unlike a list, which you can iterate as many times as you want. If you need to iterate the same sequence multiple times, that's a real list, not a generator.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What two things does Python actually do to make a for loop work on any iterable?
2. What happens when you call a generator function like count_up_to(3)?
3. What happens if you try to iterate over the same generator a second time after fully consuming it once?
4. Why would all_squares_gen(10_000_000) use far less memory than all_squares_list(10_000_000)?