A generator is a function that produces a lazy sequence of values one at
a time. Any function containing yield becomes a generator function: calling
it doesn't run the body — it returns a generator object (an iterator). Each
time you call next() (or iterate), the body runs until the next yield, hands
back that value, and pauses, preserving all local state.
def counter():
print("start")
yield 1
yield 2 # execution pauses here between next() calls
yield 3
g = counter() # nothing printed yet — body hasn't run
next(g) # prints "start", returns 1
next(g) # returns 2 (resumes after first yield)
When the function returns (or falls off the end), a StopIteration is
raised to signal exhaustion. Generators are the simplest way to write a custom
iterator without manually implementing __iter__/__next__.
A list materializes every element in memory at once, so its footprint grows with the number of items. A generator holds only its current state and computes each value on demand, so its memory use is roughly constant regardless of how many values it ultimately yields.
import sys
nums = [n * n for n in range(1_000_000)] # ~8 MB list, built eagerly
gen = (n * n for n in range(1_000_000)) # lazy — tiny, fixed size
sys.getsizeof(nums) # large
sys.getsizeof(gen) # ~100 bytes, regardless of range
This makes generators ideal for large or streaming data — reading a
multi-gigabyte file line by line, or a pipeline of transformations — where you'd
never want the whole dataset in RAM. The trade-off: you can only iterate a
generator once, and you can't index or len() it.
They share syntax but differ in their brackets and behaviour. A list
comprehension uses [...] and builds the entire list eagerly. A
generator expression uses (...) and produces a lazy iterator that
yields values one at a time, computing nothing until consumed.
lc = [x * 2 for x in range(5)] # [0, 2, 4, 6, 8] — built now
ge = (x * 2 for x in range(5)) # <generator object> — built on demand
# parentheses are optional when it's the sole argument:
total = sum(x * 2 for x in range(5)) # streams — no temp list
Prefer a generator expression when feeding an aggregator like sum, max,
any, or join over a large source — it avoids creating a throwaway list. Use a
list comprehension when you need the full result repeatedly, want to index
it, or need len().
yield from <iterable> delegates to a sub-iterator: it yields every
value from the iterable as if you'd written a loop of yields, but also
transparently forwards send, throw, and the sub-generator's
return value. It's the clean way to compose or flatten generators.
def chain(*iterables):
for it in iterables:
yield from it # vs: for x in it: yield x
list(chain([1, 2], (3, 4))) # [1, 2, 3, 4]
def sub():
yield 1
return 99 # captured by the delegator
def main():
result = yield from sub() # result == 99
Beyond saving a loop, yield from is what makes generator delegation and
coroutine composition possible. Rule of thumb: use it whenever you want one
generator to fully drain another.
Because generators are lazy, the body only advances when a value is requested — so an unbounded loop is fine: it never tries to produce all values at once. You control termination from the consumer side, by stopping iteration whenever you've taken enough.
def naturals():
n = 0
while True: # infinite — but harmless
yield n
n += 1
from itertools import islice
list(islice(naturals(), 5)) # [0, 1, 2, 3, 4] — take just 5
This underpins itertools.count, cycle, and repeat, and lets you model
streams elegantly. The danger is forgetting to bound the consumer:
list(naturals()) or for x in naturals(): print(x) will run forever — pair
infinite generators with islice, a break, or takewhile.
send(value) resumes a paused generator and makes the yield expression evaluate
to value, turning the generator into a two-way coroutine. You must first
"prime" it (advance to the first yield) with next() or send(None).
def accumulator():
total = 0
while True:
x = yield total # receives value sent in
total += x
acc = accumulator()
next(acc) # prime -> yields 0
acc.send(10) # 10
acc.send(5) # 15
Rule of thumb: use send for stateful coroutines that consume pushed values; always
prime with next() before the first send(non-None).
A return in a generator stops iteration and its value becomes the
StopIteration.value — not yielded. You normally retrieve it via yield from,
or by catching StopIteration.
def gen():
yield 1
yield 2
return "done" # not yielded
g = gen()
next(g); next(g)
try:
next(g)
except StopIteration as e:
e.value # 'done'
def wrapper():
result = yield from gen() # result == 'done'
Rule of thumb: return x in a generator sets StopIteration.value; capture it with
yield from, not by expecting a yielded item.
close() raises GeneratorExit inside the paused generator so it can run
cleanup (e.g. in a finally). throw(exc) injects an exception at the current
yield, letting the generator handle or propagate it.
def worker():
try:
while True:
x = yield
print("got", x)
finally:
print("cleanup") # runs on close()
w = worker(); next(w)
w.send(1) # got 1
w.close() # raises GeneratorExit -> prints "cleanup"
Rule of thumb: put resource cleanup in a finally so close() (and GC) can release
it; throw() is for signaling errors into a coroutine.
Chain generators so each stage lazily consumes the previous one — data flows item-by-item with constant memory, never materializing intermediate lists. Great for large files and streams.
def read(path):
with open(path) as f:
for line in f:
yield line.rstrip()
def grep(lines, term):
for line in lines:
if term in line:
yield line
def upper(lines):
for line in lines:
yield line.upper()
for line in upper(grep(read("log.txt"), "ERROR")):
print(line) # streamed end to end
Rule of thumb: compose small generator stages for memory-efficient pipelines; each stage pulls one item at a time from the one before it.
A generator is its own iterator — it has no stored collection to restart from, only a current execution position. Once exhausted (or partially consumed), there's no "rewind"; you must create a new generator to iterate again.
g = (x for x in range(3))
list(g) # [0, 1, 2]
list(g) # [] — already exhausted
sum_, max_ = sum(g), max(g) # BUG if g is consumed by the first call
data = list(range(3)) # materialize if you need multiple passes
Rule of thumb: generators are single-pass; if you need to iterate twice (or use both
sum and max), store the values in a list first or rebuild the generator.
Laziness means you get the first result immediately without computing the rest — time-to-first-item is low and you can stop early. With an eager list you pay the full cost upfront even if you only need one match.
def find_first(it, pred):
for x in it:
if pred(x):
return x # stops as soon as found
# generator computes only until the match:
find_first((expensive(n) for n in range(10**9)), lambda v: v > 100)
Rule of thumb: generators let you short-circuit — combine with next(), any, or a
break to avoid computing values you'll never use.
A generator function captures all the iteration state in local variables and the
pause point automatically — no manual self.index bookkeeping, no explicit
StopIteration. It's far less code for the same iterator behavior.
# verbose class iterator:
class Countdown:
def __init__(self, n): self.n = n
def __iter__(self): return self
def __next__(self):
if self.n <= 0: raise StopIteration
self.n -= 1; return self.n + 1
# equivalent generator:
def countdown(n):
while n > 0:
yield n
n -= 1
Rule of thumb: reach for a generator function for almost all custom iteration; use a class only when you need extra methods/attributes alongside iteration.
A generator keeps its own frame (locals, instruction pointer) alive across
suspensions. When paused at a yield, all locals retain their values; the next
next() resumes exactly where it left off — like a saved stack frame.
def running_total():
total = 0
for x in [10, 20, 30]:
total += x # `total` survives across yields
yield total
list(running_total()) # [10, 30, 60]
Rule of thumb: each generator instance has independent, persistent local state — that's why two calls to the same generator function don't interfere.
For recursive structures (trees, nested lists), yield from lets a generator
delegate to a recursive call cleanly, flattening the structure without manual loops
at each level.
def flatten(items):
for item in items:
if isinstance(item, list):
yield from flatten(item) # recurse and forward all values
else:
yield item
list(flatten([1, [2, [3, 4], 5], 6])) # [1, 2, 3, 4, 5, 6]
Rule of thumb: yield from recursive_call(...) is the idiom for recursively walking
nested data lazily — no accumulator list needed.
Passing one generator to two consumers means the second sees nothing — the first drained it. This bites when you compute several aggregates over the same source.
gen = (n for n in range(5))
total = sum(gen) # 10 — consumes gen
count = len(list(gen)) # 0! — already exhausted
avg = total / count # ZeroDivisionError
data = list(range(5)) # fix: materialize once
total, count = sum(data), len(data)
Rule of thumb: if you need multiple passes or several aggregates, convert the
generator to a list first (or use itertools.tee for limited re-iteration).
More Comprehensions & Iteration interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.