Frontend Labs
⋯

Part II — Intermediate: idiomatic Python

Part I taught enough Python to write scripts. Part II teaches the features that make Python code short, lazy, reusable, and checkable: generators, decorators, classes and the data model, protocols, type hints, the standard library, tests, and packaging. Every library you will use in Part VII is built from these pieces, so this Part is also how you learn to read their source.


13. Iterators and generators: lazy pipelines

13.1 The iterator protocol

for x in obj works on anything iterable. Under the hood:

nums = [10, 20]
it = iter(nums)        # calls nums.__iter__() -> an iterator
print(next(it))        # 10   (calls it.__next__())
print(next(it))        # 20
next(it)               # StopIteration: the for loop catches this and stops

An iterable has __iter__ (lists, dicts, files, strings). An iterator also has __next__ and remembers its position. Iterators are single-use: once exhausted, they stay exhausted.

squares = (n * n for n in range(3))    # a generator expression is an iterator
print(list(squares))                   # [0, 1, 4]
print(list(squares))                   # []  <- already consumed

That second empty list is a common bug when a generator is passed to two consumers. If you need the values twice, store them in a list.

13.2 Generators: functions that pause

A function containing yield returns a generator. Each next() runs the body until the next yield, hands out the value, and freezes the function's local state until the next call:

def countdown(n: int):
    print("start")
    while n > 0:
        yield n
        n -= 1
    print("done")

g = countdown(2)       # nothing runs yet
print(next(g))         # start, then 2
print(next(g))         # 1
print(list(g))         # done, then []

Generators give you lazy evaluation: values are produced one at a time, on demand. That means constant memory for unbounded or huge inputs.

13.3 Real-world example: a streaming log pipeline

Chain generators like Unix pipes. Each stage pulls one item at a time from the previous one, so a 50 GB log flows through in a few kilobytes of memory:

import gzip
import json
from collections.abc import Iterable, Iterator
from pathlib import Path

def read_lines(paths: Iterable[Path]) -> Iterator[str]:
    for p in paths:
        opener = gzip.open if p.suffix == ".gz" else open
        with opener(p, "rt", encoding="utf-8", errors="replace") as f:
            yield from f                       # delegate to the file iterator

def parse_json(lines: Iterable[str]) -> Iterator[dict]:
    for line in lines:
        try:
            yield json.loads(line)
        except json.JSONDecodeError:
            continue

def only_errors(events: Iterable[dict]) -> Iterator[dict]:
    return (e for e in events if e.get("level") == "error")

# Build a sample input, then run the pipeline.
Path("app.jsonl").write_text(
    '{"level": "info", "msg": "up"}\n{"level": "error", "msg": "db timeout"}\nnot json\n'
)
for e in only_errors(parse_json(read_lines([Path("app.jsonl")]))):
    print(e["msg"])                            # db timeout

This is the same pattern as cat *.gz | zcat | jq | grep in OS Ch 28, and the same pattern PyTorch's IterableDataset and Hugging Face's streaming datasets use to feed terabytes of training text (Ch 60).

13.4 itertools and friends

import itertools as it

print(list(it.islice(it.count(10, 5), 4)))        # [10, 15, 20, 25]: slice an infinite iterator
print(list(it.chain([1, 2], (3,), "ab")))          # [1, 2, 3, 'a', 'b']
print(list(it.batched(range(7), 3)))               # [(0, 1, 2), (3, 4, 5), (6,)]  (3.12+)
print(list(it.pairwise([1, 4, 9, 16])))            # [(1, 4), (4, 9), (9, 16)]
print(list(it.accumulate([3, 1, 4], initial=0)))   # [0, 3, 4, 8]: running totals
for key, group in it.groupby(sorted(["ant", "bee", "asp"]), key=lambda w: w[0]):
    print(key, list(group))                        # groupby needs sorted input
print(list(it.product("ab", [0, 1])))              # cartesian product: grid search
print(list(it.combinations([1, 2, 3], 2)))
print(any(x > 2 for x in [1, 3]), all([]), max([], default=0))

itertools.batched is what you use to send embeddings or database rows in groups of 64. product is a hyperparameter grid.

13.5 Generators that receive values, and cleanup

yield is an expression: gen.send(value) resumes the generator and makes yield return value. This two-way channel is how coroutines started in Python, and why async def coroutines (Ch 29) are built on the same machinery. You will rarely write send yourself, but you will rely on the cleanup guarantee: when a generator is closed or garbage-collected, GeneratorExit is raised at the paused yield, so a finally or with inside the generator runs.

def reader():
    print("open")
    try:
        yield 1
        yield 2
    finally:
        print("close")    # runs even if the consumer stops early

for x in reader():
    print(x)
    break                 # open, 1, close

Generated from real-life-python-guide.md · 8 Oct 2026 16:36