Generators and Iterators
Any object usable in a for loop implements the iterator protocol: __iter__ returns an iterator, and that iterator's __next__ produces one value at a time, raising StopIteration when exhausted.
🎯 What you'll learn
- The Iterator Protocol
- Generator Functions —
yieldInstead of Hand-Written Classes - Generators Are Iterators (But Not All Iterators Are Generators)
- Generator Expressions
send(),close(), and Two-Way Communication
1The Iterator Protocol
Any object usable in a for loop implements the iterator protocol: __iter__ returns an iterator, and that iterator's __next__ produces one value at a time, raising StopIteration when exhausted.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
for n in Countdown(3):
print(n) # 3, 2, 1A for loop is sugar for calling iter(obj) once, then calling next() repeatedly until StopIteration is raised — that exception is how the loop knows to stop, not an error condition.
2Generator Functions — yield Instead of Hand-Written Classes
Writing __iter__/__next__ by hand is verbose. A generator function — any function containing yield — does the same job automatically:
def countdown(start):
while start > 0:
yield start
start -= 1
g = countdown(3)
print(next(g)) # 3
print(next(g)) # 2
print(next(g)) # 1
print(next(g)) # raises StopIterationCalling countdown(3) does not run any of the function body. It returns a generator object immediately. The body only starts executing on the first next() call, and each yield pauses execution exactly where it is, handing a value back to the caller.
3Generators Are Iterators (But Not All Iterators Are Generators)
def countdown(start):
while start > 0:
yield start
start -= 1
g = countdown(3)
print(iter(g) is g) # True — a generator is its own iteratorEvery generator object automatically has __iter__ (returning itself) and __next__ — it satisfies the iterator protocol from section 1 for free. This is why for n in countdown(3): works with no extra code: generators are simply the easiest way to write something that satisfies the iterator protocol.
4Generator Expressions
A generator expression is to a generator function what a list comprehension is to a loop — a compact, lazy equivalent:
squares_list = [x * x for x in range(1_000_000)] # builds the ENTIRE list in memory now
squares_gen = (x * x for x in range(1_000_000)) # builds NOTHING yet; lazy
print(next(squares_gen)) # 0 — computed on demand
print(next(squares_gen)) # 1Prefer a generator expression whenever the values are only consumed once, in order — e.g. sum(x * x for x in data) never materializes the intermediate list at all.
5send(), close(), and Two-Way Communication
yield is an expression, not just a statement — a generator can receive a value through send(), not only produce one:
def running_total():
total = 0
while True:
amount = yield total
total += amount
acc = running_total()
next(acc) # prime the generator (advance to the first yield)
print(acc.send(10)) # 10
print(acc.send(5)) # 15
acc.close() # raises GeneratorExit inside the generator, stopping itnext(acc) must run first to advance the generator to its first yield before send() has anywhere to deliver a value into. close() requests early termination by throwing GeneratorExit at the suspended yield point.
6yield from — Delegating to a Sub-Generator
def inner():
yield 1
yield 2
def outer():
yield "start"
yield from inner() # transparently yields 1, then 2
yield "end"
print(list(outer())) # ['start', 1, 2, 'end']yield from flattens one level of delegation: values yielded by inner() pass through outer() untouched, and outer()'s generator also correctly forwards send()/close()/exceptions down into inner() — something a manual for x in inner(): yield x loop does not do for send/throw.
7itertools — Building Blocks for Lazy Iteration
import itertools
evens = itertools.count(0, 2) # infinite: 0, 2, 4, 6, ...
first_five = itertools.islice(evens, 5) # take exactly 5, lazily
print(list(first_five)) # [0, 2, 4, 6, 8]
print(list(itertools.chain([1, 2], [3, 4]))) # [1, 2, 3, 4] — lazily concatenates
print(list(itertools.zip_longest([1, 2], [1], fillvalue=0))) # [(1, 1), (2, 0)]itertools functions all follow the same discipline as generators: nothing is computed until something actually iterates the result, which is what makes itertools.count() (an infinite sequence) safe to create — it's only a problem if you fully consume it without a bound like islice.
Under the HoodFrames, Suspension, and Why a Generator Is One-Shot▾
A normal function call creates a stack frame, runs it to completion, and discards it — there is nothing left to resume. A generator function instead gets its frame allocated on the heap, owned by the generator object itself (accessible via gi_frame on older CPython internals), which is exactly what makes suspension possible: the frame simply isn't destroyed when a yield is hit.
Every frame tracks f_lasti — the offset of the last bytecode instruction executed. When a generator hits yield, CPython:
- Evaluates the yielded expression.
- Records the current
f_lasti(pointing at theYIELD_VALUEbytecode). - Returns control to the caller, leaving the frame's local variables, evaluation stack, and
f_lastiexactly as they are — not garbage collected, because the generator object holds a live reference to the frame.
The next next() call doesn't re-run the function from the top: it resumes bytecode execution at f_lasti + 1, with every local variable exactly where it was left, including partially-evaluated expressions still sitting on the frame's internal value stack. This is a fundamentally different execution model from a normal function call, and it's the precise mechanism the diagram above shows — next(g) the second time doesn't call countdown again, it resumes the same suspended frame.
When the generator body finally runs off the end (or hits return), CPython raises StopIteration internally, and — critically — releases the frame. gi_frame becomes None, and there is no way to get it back: every subsequent next() call on that same generator object immediately raises StopIteration again, because there is no frame left to resume. This is why generators are single-use — the suspension mechanism depends on one specific frame instance staying alive, and that frame is deliberately freed the moment the generator finishes, to release whatever memory its locals were holding.
yield from and send() reuse this exact machinery: send(value) resumes the frame just like next(), except the paused YIELD_VALUE expression evaluates to value instead of None; throw() resumes the frame by injecting an exception at the suspended instruction pointer instead of a normal value.
⚠️ Common Pitfalls
- Assuming a generator function's body runs when it's called —
g = countdown(3)runs zero lines ofcountdown's body; nothing happens until the firstnext(g). A common bug is putting validation logic at the top of a generator function and expecting it to raise immediately — it only raises on first iteration. - Iterating a generator twice — a generator is exhausted after one full pass;
list(g)a second time on the same object returns[], since there's no frame left to resume (see Under the Hood). If you need to iterate more than once, call the generator function again, or use a class/list instead. - Forgetting to
next()-prime a generator before the firstsend()—send(value)on a freshly created, never-advanced generator raisesTypeError, because there's no suspendedyieldexpression yet to delivervalueinto. - Building an infinite generator without a limiting step —
for x in itertools.count():with nobreak/islicenever terminates; always pair an unbounded generator with an explicit stopping condition. - Using a generator expression when you need to iterate more than once or need
len()— a generator expression, like a generator function, is exhausted after one pass and has no__len__; use a list/tuple comprehension instead when either property is needed.
📋 Quick Reference
def gen():
yield 1
yield 2 # generator function — lazy, one value per next()
g = gen()
next(g) # advances to the next yield, returns its value
g.send(value) # resumes, delivering `value` as the yield expression's result
g.close() # raises GeneratorExit at the suspended point
g.throw(SomeError) # raises SomeError at the suspended point
(x * x for x in range(10)) # generator expression — lazy equivalent of a list comp
def outer():
yield from inner() # delegate to a sub-generator, forwarding send/throw too
itertools.count(start, step) # infinite arithmetic sequence
itertools.islice(iterable, n) # take first n, lazily
itertools.chain(a, b) # lazily concatenate iterables🎤 Interview Q&A
Q1What is the iterator protocol in Python?core▾
An iterable implements __iter__ returning an iterator; an iterator implements __next__ returning items and raising StopIteration when done. for loops use this protocol.
- iter() and next()? Built-ins calling those methods.
Q2What are generators and why use them?core▾
Functions with yield that produce values lazily, pausing state between yields. They save memory for large or infinite sequences and make pipelines (read → parse → filter) composable.
- yield from? Delegates to a sub-generator.
Q3What does itertools offer?core▾
Lazy building blocks: chain, islice, groupby, product, permutations, combinations, accumulate, count, cycle, batched (3.12), pairwise.
- groupby caveat? Input must be sorted by the key.
Q4Can generators receive values?senior▾
Yes: send(value) resumes a generator and makes yield evaluate to the value; throw() and close() inject exceptions. This underlies coroutines historically.
- Modern coroutines? async/await replaced generator-based coroutines.
Q5How do you process a huge file efficiently?core▾
Iterate the file object line by line (for line in f), use generator pipelines, and avoid read()/readlines() that load everything into memory.
- CSV?
csv.readerover the file iterator.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.