Generators & Lazy Evaluation¶
What this lesson gives you
How yield transforms a function into a resumable coroutine, how generator pipelines process multi-gigabyte files with kilobytes of RAM, and where generators sit on the spectrum toward full async/await concurrency.
Estimated time: 32 min read · Part: Functions & the Functional Toolkit
Every time Python encounters a yield statement inside a function, it transforms that function from a regular callable into a generator function. Calling a generator function doesn't run its body – it returns a generator object, a suspended state machine that resumes from exactly where it left off each time you call next() on it. This lazy, on-demand production of values is what makes it possible to iterate over a trillion integers or process a 50 GB log file in constant memory. Generators also underpin Python's asyncio event loop and the yield from delegation mechanism, making them the gateway to understanding all modern Python concurrency.
The yield Statement: Creating Generator Functions¶
def count_up(start, stop):
"""Generator: yields integers from start to stop, inclusive."""
current = start
while current <= stop:
print(f" >> about to yield {current}")
yield current
print(f" >> resumed after yield {current}")
current += 1
print(" >> generator exhausted")
gen = count_up(1, 3)
print(type(gen)) # <class 'generator'>
print("Calling next()...")
print(next(gen)) # >> about to yield 1 | 1
print(next(gen)) # >> resumed ... | >> about to yield 2 | 2
for val in gen: # drives remaining values
print(val) # 3
# Second call creates a fresh generator – they don't restart
gen2 = count_up(1, 3)
print(list(gen2)) # [1, 2, 3]
yield is a two-way checkpoint, not a return
When Python hits yield value, it (1) suspends the function's stack frame, (2) sends value to the caller, and (3) waits. The frame's local variables, loop counters, and instruction pointer are all preserved on the heap. The next next() call restores that frame and continues from the line after yield. This is fundamentally different from a function call – no new frame is created and no state is reset.
Infinite Streams and Generator Pipelines¶
import itertools
# Infinite generator – never exhausted
def integers(start=0):
n = start
while True:
yield n
n += 1
# Pipeline stages: each is a generator consuming another generator
def evens(src):
for n in src:
if n % 2 == 0:
yield n
def squared(src):
for n in src:
yield n * n
def take(n, src):
for _ in range(n):
yield next(src)
# Compose the pipeline (nothing executes yet)
pipeline = take(5, squared(evens(integers(1))))
# Pull values through on demand
print(list(pipeline)) # [4, 16, 36, 64, 100]
# integers(1) → 1,2,3,4,5,6,7,8,9,10,...
# evens → 2,4,6,8,10,...
# squared → 4,16,36,64,100,...
# take(5) → [4,16,36,64,100] (stops pulling)
yield from — Delegating to Sub-Generators (PEP 380)¶
def flatten(nested):
"""Recursively flatten arbitrarily nested lists."""
for item in nested:
if isinstance(item, list):
yield from flatten(item) # delegate to recursive call
else:
yield item
data = [1, [2, [3, 4], 5], [6, 7], 8]
print(list(flatten(data))) # [1, 2, 3, 4, 5, 6, 7, 8]
# Without yield from, you'd need:
# for sub_item in flatten(item):
# yield sub_item
# yield from is cleaner AND passes send() / throw() / close() through
# to the sub-generator (critical for coroutine chaining)
# yield from also captures the sub-generator's StopIteration value
def sub():
yield 1
yield 2
return "sub_done" # StopIteration value
def delegator():
result = yield from sub() # 'result' gets "sub_done"
print(f"sub returned: {result}")
yield 99
gen = delegator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # sub returned: sub_done | 99
Two-Way Communication: send(), throw(), close()¶
def running_average():
"""Coroutine that accepts values via send() and yields the average."""
total = 0.0
count = 0
value = yield # prime: first next() runs to here, returns None
while True:
total += value
count += 1
value = yield total / count # send average back, receive next value
avg = running_average()
next(avg) # prime the coroutine (advance to first yield)
print(avg.send(10)) # 10.0
print(avg.send(20)) # 15.0
print(avg.send(30)) # 20.0
print(avg.send(40)) # 25.0
# throw() injects an exception at the yield point
def resilient():
try:
while True:
value = yield
print(f"Got: {value}")
except ValueError as e:
print(f"Caught inside generator: {e}")
yield "handled"
r = resilient()
next(r)
r.send(42) # Got: 42
print(r.throw(ValueError, "bad input")) # Caught... | 'handled'
# close() raises GeneratorExit at the yield point
gen = (x for x in range(100))
print(next(gen)) # 0
gen.close() # generator is now exhausted / closed
return in a generator raises StopIteration with a value
Inside a generator, return value raises StopIteration(value). The for loop silently discards this value. To capture it, either use yield from (which assigns it to a variable) or catch the StopIteration manually: try: next(gen) except StopIteration as e: print(e.value).
How Python's for Loop Uses Generators Internally¶
# This for loop:
for x in [1, 2, 3]:
print(x)
# Is exactly equivalent to:
_iter = iter([1, 2, 3]) # calls list.__iter__()
while True:
try:
x = next(_iter) # calls _iter.__next__()
print(x)
except StopIteration:
break
# Generators satisfy the iterator protocol natively:
# gen.__iter__() returns self
# gen.__next__() resumes and returns the next yielded value
# So generators can be used directly in for loops, zip(), map() etc.
Memory Efficiency: Generator vs List¶
import sys, tracemalloc
N = 1_000_000
# Eager list
tracemalloc.start()
result_list = [n * n for n in range(N)]
_, list_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
# Lazy generator
tracemalloc.start()
result_gen = (n * n for n in range(N))
_, gen_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"List peak: {list_peak / 1_048_576:.2f} MB")
print(f"Generator peak: {gen_peak / 1_048_576:.4f} MB")
print(f"Ratio: {list_peak // gen_peak}x more memory for list")
output List peak: 8.13 MB Generator peak: 0.0001 MB Ratio: ~80000x more memory for list
Real Data Engineering: Streaming Multi-GB File Processing¶
import csv, gzip, itertools
from pathlib import Path
from typing import Iterator
def read_gzipped_csv(path: Path) -> Iterator[dict]:
"""Stream rows from a gzipped CSV without loading it into memory."""
with gzip.open(path, "rt", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def parse_numerics(rows: Iterator[dict]) -> Iterator[dict]:
"""Cast revenue and quantity to correct types."""
for row in rows:
row["revenue"] = float(row.get("revenue", 0) or 0)
row["quantity"] = int(row.get("quantity", 0) or 0)
yield row
def filter_region(rows: Iterator[dict], region: str) -> Iterator[dict]:
for row in rows:
if row.get("region") == region:
yield row
def aggregate_by_product(rows: Iterator[dict]) -> dict:
totals = {}
for row in rows:
pid = row["product_id"]
totals[pid] = totals.get(pid, 0.0) + row["revenue"]
return totals
# Compose pipeline – nothing runs yet
files = Path("data").glob("sales_*.csv.gz") # e.g., 50 files @ 1 GB each
raw = itertools.chain.from_iterable(read_gzipped_csv(f) for f in files)
parsed = parse_numerics(raw)
apac = filter_region(parsed, "APAC")
result = aggregate_by_product(apac) # pulls the pipeline
# Peak memory stays near the size of one CSV row, never the full dataset
print(sorted(result.items(), key=lambda x: x[1], reverse=True)[:5])
Story – 48 GB log file, 180 MB RAM budget
A cloud infrastructure team needed to parse 48 GB of compressed access logs to compute per-customer bandwidth totals for billing. The first attempt used Pandas: it read all files into a DataFrame, consumed 180 GB of RAM, and was killed by OOM. A rewrite using a three-stage generator pipeline (read → filter → aggregate) ran in 94 seconds, peak RAM 142 MB – within their container's limit. The pipeline read, filtered, and aggregated each compressed chunk line-by-line, never holding more than a handful of rows in memory at once.
Analogy – A generator is an assembly line, not a warehouse
A list is a warehouse: you manufacture all the goods, stack them in the building, then ship them in one truck. A generator is an assembly line: you manufacture one item, hand it to the next station, and that station hands it on. The warehouse needs space proportional to total output; the assembly line needs space proportional to one item.
Generators vs async/await for I/O-Bound Tasks¶
| Approach | Concurrency model | Best for | Key mechanism |
|---|---|---|---|
| Generator pipeline | Single-threaded, pull-based | CPU-bound transformations, streaming | yield / next() |
| async/await | Single-threaded, event-loop | I/O-bound (network, disk), high concurrency | await (generators under the hood) |
| Threading | OS threads, GIL-constrained | I/O-bound, blocking C extensions | threading.Thread |
| Multiprocessing | Multiple processes | CPU-bound, bypasses GIL | multiprocessing.Pool |
asyncio is built on generators
Python's asyncio event loop was originally implemented using yield from-based coroutines (PEP 342, PEP 380). When Python 3.5 introduced async def / await, it replaced the old @asyncio.coroutine + yield from pattern with cleaner syntax, but the underlying mechanism – suspending execution at a checkpoint and resuming later – is exactly the same generator protocol you've learned here. Understanding generators makes async/await comprehensible rather than magical.
flowchart LR
subgraph pipeline ["Generator Pipeline (lazy, pull-based)"]
direction LR
A["read_gzipped_csv<br/>(source)"] -->|yield row| B["parse_numerics<br/>(transform)"] -->|yield row| C["filter_region<br/>(filter)"] -->|yield row| D["aggregate_by_product<br/>(sink)"]
end
D -->|"dict result"| Result["billing totals"]
style A fill:#4f46e5,color:#fff
style B fill:#7c3aed,color:#fff
style C fill:#db2777,color:#fff
style D fill:#16a34a,color:#fff Generator-Based Coroutine Pattern (pre-async)¶
from functools import wraps
def coroutine(fn):
"""Decorator to auto-prime a generator coroutine."""
@wraps(fn)
def wrapper(*args, **kwargs):
gen = fn(*args, **kwargs)
next(gen) # advance to first yield
return gen
return wrapper
@coroutine
def grep(pattern):
"""Coroutine that receives lines and prints matches."""
import re
while True:
line = yield
if re.search(pattern, line):
print(f" MATCH: {line.rstrip()}")
@coroutine
def broadcast(*targets):
"""Fan-out: send each received value to multiple coroutines."""
while True:
value = yield
for target in targets:
target.send(value)
# Build a coroutine pipeline
errors = grep(r"ERROR")
timeouts = grep(r"timeout")
router = broadcast(errors, timeouts)
lines = [
"2024-01-01 INFO service started\n",
"2024-01-01 ERROR disk full\n",
"2024-01-01 ERROR connection timeout\n",
"2024-01-01 DEBUG heartbeat ok\n",
]
for line in lines:
router.send(line)
router.close()
output MATCH: 2024-01-01 ERROR disk full MATCH: 2024-01-01 ERROR connection timeout MATCH: 2024-01-01 ERROR connection timeout
Generators are single-pass: you can't rewind them
Once a generator raises StopIteration, it's permanently exhausted. Calling next() or iterating again produces nothing – you get StopIteration immediately. If you need to iterate multiple times, either store results in a list (list(gen)) or restructure as a class with __iter__ that creates a fresh generator each time. This trips up many developers who convert a list to a generator expression and then iterate it twice.
Consulting lens – Generators as the scalability lever
When a client's data pipeline is hitting memory or throughput limits, the first question to ask is: "Where are we materialising intermediate results?" Replacing eager list comprehensions with generator expressions and chaining stages with itertools.chain is often a same-day fix that delivers a 10–100× memory reduction with no architecture change. This is one of the highest-leverage, lowest-risk interventions in Python performance consulting.
Knowledge check
InterviewWhat happens when you call return "done" inside a generator function?
- The string "done" is yielded to the caller
- A SyntaxError is raised at definition time
- StopIteration is raised with .value == "done"
- The generator resets and starts from the beginning
Answer
StopIteration is raised with .value == "done"
Inside a generator, return value raises StopIteration(value) . A for loop catches StopIteration silently and discards the value. To access it you use yield from (the result is captured in the assignment: result = yield from sub_gen() ) or catch it manually: try: next(gen) except StopIteration as e: print(e.value) . This mechanism is the foundation of how asyncio tasks communicate results back to the event loop.
Exercise
Exercise 4.4 · Streaming Word Frequency Counter Build a generator pipeline that: (1) reads lines from a text file lazily (read_lines(path)), (2) tokenises each line into lowercase words (tokenise(lines)), (3) filters stop-words from a given set (remove_stops(words, stops)), and (4) a sink function word_freq(words) that consumes the pipeline and returns a Counter of word frequencies. Then use itertools.islice to show the top 5 words. All pipeline stages must be generators.
Show solution
import re, itertools
from collections import Counter
from io import StringIO
# Pipeline stages
def read_lines(fileobj):
for line in fileobj:
yield line
def tokenise(lines):
for line in lines:
for word in re.findall(r"[a-z]+", line.lower()):
yield word
def remove_stops(words, stops):
for word in words:
if word not in stops:
yield word
def word_freq(words):
return Counter(words)
# Demo with in-memory text
text = StringIO("""
The quick brown fox jumps over the lazy dog.
A fox is a quick and clever animal.
The dog was not amused by the fox at all.
""")
STOPS = {"the", "a", "an", "is", "by", "and", "at", "was", "not", "over"}
pipeline = remove_stops(tokenise(read_lines(text)), STOPS)
freq = word_freq(pipeline)
top5 = list(itertools.islice(
(f"{w}: {c}" for w, c in freq.most_common()),
5
))
print(top5)
# ['fox: 3', 'quick: 2', 'dog: 2', 'brown: 1', 'jumps: 1']
Key takeaways
- A generator function (containing
yield) returns a generator object when called; the body doesn't run untilnext()is called. - Execution pauses at
yieldand resumes from that exact point on the nextnext()call, preserving all local state. yield from subgendelegates to a sub-generator, transparently routingsend(),throw(), andclose()and capturing the sub-generator'sreturnvalue.generator.send(value)both resumes the generator and injects a value at theyieldexpression, enabling two-way coroutine communication.return valueinside a generator raisesStopIteration(value); useyield fromto capture the value.- Generator pipelines process arbitrarily large data in constant memory – only one element lives in the pipeline at a time.
- Python's
async def/awaitis built on the same generator protocol; generators are the conceptual foundation of all Python concurrency.