Skip to content

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

basic_generator.py
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

pipeline.py
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)

yield_from.py
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()

send_throw.py
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

for_internals.py
# 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

memory_compare.py
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

streaming_etl.py
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)

coroutine_pattern.py
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
exercise_4_4.py
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 until next() is called.
  • Execution pauses at yield and resumes from that exact point on the next next() call, preserving all local state.
  • yield from subgen delegates to a sub-generator, transparently routing send(), throw(), and close() and capturing the sub-generator's return value.
  • generator.send(value) both resumes the generator and injects a value at the yield expression, enabling two-way coroutine communication.
  • return value inside a generator raises StopIteration(value); use yield from to 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 / await is built on the same generator protocol; generators are the conceptual foundation of all Python concurrency.