TL;DR — Four language features that stop being optional the moment code goes to production. Generators turn memory from a function of stream length into a constant — 200 bytes instead of 444 KB for 50,000 items in the example below — which is why streaming a model's tokens or reading a large file is always
yield, never a list. Decorators let you write retry, timing, or auth once and attach it with a line, provided you usefunctools.wraps, without which the wrapped function loses its name and docstring and your logs start reporting every function aswrapper. Context managers guarantee cleanup runs even when the body raises, and the single most important detail is that__exit__must return a falsy value — returningTruesilently swallows the exception, converting a crash into a wrong answer. Dataclasses replace ad-hoc dicts with typed fields and free__repr__/__eq__, andfield(default_factory=list)is the language's built-in fix for the mutable-default bug — a baretags: list = []refuses to compile at all.
1. Simple explanation
Each of these four features exists to solve a problem that only appears at scale or under failure.
A generator is a function that pauses. Instead of building a whole list and handing it back, it produces one item, waits until you ask for the next, then resumes where it left off. The consequence is that it never holds more than one item at a time, so memory does not grow with the size of the stream. A function that returns a million rows and a function that yields a million rows do the same work, but only one of them fits in RAM.
A decorator is a function that wraps another function to add behaviour around it — retrying, timing, logging, checking permissions. You write the behaviour once and apply it with @retry above any function that needs it. Without decorators, that logic gets copy-pasted into every call site and drifts out of sync.
A context manager is the with block. It runs setup, gives you the resource, and runs teardown — and the teardown happens whether the body succeeded, returned early, or raised. It exists because try/finally written by hand gets forgotten exactly on the paths that matter.
A dataclass is a class where you declare the fields and Python writes the boilerplate. You get a constructor, a readable repr, and value-based equality from four lines of field declarations.
Analogy — a water tap versus a delivery of bottles. If you need a litre of water, you can have a lorry deliver a thousand bottles and take what you need, or you can open a tap. The tap gives you exactly as much as you draw, and it does not matter whether the reservoir holds a thousand litres or a billion — your kitchen is the same size either way. A generator is the tap. The bottles are the list, and the reason people keep ordering lorries is that for small quantities it works fine and the failure only appears when someone asks for the reservoir.
2. Diagram
GENERATOR vs LIST -- where the items live
LIST GENERATOR
return [f(i) for i in range(n)] for i in range(n): yield f(i)
┌─────────────────────────────┐ ┌───┐
│ item0 item1 item2 ... itemN │ │ i │ ← ONE item, then discarded
└─────────────────────────────┘ └───┘ state = one integer
all built BEFORE you see any first item available immediately
50,000 chunks → 444,376 bytes 50,000 chunks → 200 bytes
(container only; (constant, for ANY n)
strings extra)
DECORATOR -- a function wrapping a function
@retry(times=3) flaky_api = retry(times=3)(flaky_api)
def flaky_api(): ... ↑ this is all @ means
caller ──► wrapper ──► fn attempt 1 ✗ ConnectionError
│ └──► fn attempt 2 ✗ ConnectionError
│ └──► fn attempt 3 ✓ "ok"
└──► return "ok"
@functools.wraps(fn) copies __name__, __doc__, __module__ across.
Without it: flaky_api.__name__ == "wrapper" ← logs become useless
CONTEXT MANAGER -- __exit__ runs on BOTH paths
with Timer("work"): success failure
body ┌──────────┐ ┌──────────┐
│ __enter__│ │ __enter__│
│ body │ │ body ✗ │ raises
│ __exit__ │ │ __exit__ │ ← STILL runs
└──────────┘ └────┬─────┘
│
return False ──► exception propagates ✓
return True ──► exception SWALLOWED ✗ bug
DATACLASS -- the mutable-default fix, enforced by the language
tags: list = [] → ValueError at class creation
"mutable default ... not allowed"
tags: list[str] = field( → a NEW list per instance
default_factory=list)
3. How it works
Generators
A function containing yield is not a normal function. Calling it runs no body code; it returns a generator object. Each next() runs the body until the next yield, hands back that value, and freezes the frame — local variables, instruction pointer, everything — until the next next().
That frozen frame is the whole trick. It is a small, fixed-size object regardless of how many items the generator will eventually produce, which is why sys.getsizeof reports 200 bytes for a generator that will yield 50,000 strings.
Two consequences follow that catch people out. Generators are single-use: once exhausted, iterating again yields nothing, with no error. And they are lazy, so exceptions inside the body surface when you consume, not when you call — a generator that will fail on item 900 constructs perfectly happily.
Generator expressions are the same thing in expression form: (f(x) for x in xs) instead of [f(x) for x in xs]. The only difference is the brackets, and the memory profile is completely different. sum(x*x for x in range(10**7)) is constant memory; the list-comprehension version allocates ten million integers.
Decorators
@deco above a def is exactly f = deco(f) after it. A decorator is any callable that takes a function and returns a replacement. A decorator taking arguments (@retry(times=3)) needs one more layer: retry(times=3) is called first and must return the decorator.
The mandatory detail is functools.wraps. The replacement function has its own __name__ ("wrapper"), its own docstring (None), and its own signature. Anything that introspects the function — logging that includes the function name, Flask's route registry, pytest's test collection, Sphinx's autodoc — sees the wrapper instead of your function. @functools.wraps(fn) copies the metadata across and sets __wrapped__ so tools can still reach the original. Omitting it is not a style issue; it produces logs where every entry says wrapper.
The pattern is right for cross-cutting concerns: retry, timing, caching, auth, rate limiting. It is wrong for anything that changes the function's meaning, because a decorator is invisible at the call site — a reader sees flaky_api() and has no local indication that it may run three times and sleep between attempts.
Context managers
with expr as name: calls expr.__enter__(), binds the result to name, runs the body, then calls __exit__(exc_type, exc, tb). On the success path, all three arguments are None. On the failure path they describe the exception, and the return value of __exit__ decides what happens next: falsy means the exception continues to propagate; truthy means it is suppressed.
Returning True from __exit__ is the trap. It is a one-character difference from correct code and it silently discards every exception raised in the body — including bugs. Unless the entire purpose of the context manager is to suppress a specific exception (as contextlib.suppress does, deliberately), __exit__ should return None or False. Note that a bare return at the end of __exit__ returns None, which is correct — the danger only comes from an explicit return True.
For the common cases, contextlib.contextmanager lets you write one as a generator with a single yield, which is shorter and harder to get wrong:
@contextlib.contextmanager
def timer(label):
t0 = time.perf_counter()
try:
yield
finally: # runs on both paths, and does not suppress
print(f"{label}: {(time.perf_counter()-t0)*1000:.1f} ms")
The try/finally is required. Without it, an exception in the body skips everything after the yield and the cleanup never runs.
Dataclasses
@dataclass reads the class-level annotations and generates __init__, __repr__, and __eq__. Equality is by value across all fields, which is what you almost always want and what a plain class does not give you.
Because a class body's defaults are evaluated once — the same mechanism behind the mutable-default-argument bug in Python Fundamentals — a mutable default would be shared across every instance. Rather than let that happen silently, dataclasses refuse:
ValueError: mutable default <class 'list'> for field tags is not allowed:
use default_factory
field(default_factory=list) calls list() per instance. This is the one place in Python where the language catches this class of bug for you, and it is worth noticing that the fix had to be added explicitly.
Useful variants: frozen=True makes instances immutable and hashable, so they can be dict keys or set members; slots=True (3.10+) drops the per-instance __dict__, cutting memory noticeably when you have millions of instances; order=True generates comparison methods for sorting.
What this article deliberately does not cover
async/await is the fifth feature usually bundled into "advanced Python," and it has its own article — see Async Programming, which covers the event loop, asyncio.to_thread, and the blocking-call failure that serialises a whole service. Generators and coroutines are historically related (both suspend a frame), but the concerns are different enough that mixing them into one article helps nobody.
4. The math
The generator claim is the one with quantifiable content, and it is worth stating precisely because "generators save memory" is often repeated in cases where it is false.
For a list of $n$ items, peak memory is:
$$ M_{\text{list}}(n) = C + n \cdot p + \sum_{i=1}^{n} s_i $$
where $C$ is the list object's fixed overhead (56 bytes in CPython), $p$ is the pointer size (8 bytes), and $s_i$ is the size of item $i$. The measured 444,376 bytes for 50,000 items is the container alone — $56 + 50{,}000 \times 8 = 400{,}056$, plus the over-allocation CPython uses to make append amortised O(1). The strings themselves are on top of that: at 140 characters each, roughly $50{,}000 \times 189 \approx 9.5$ MB more.
For a generator:
$$ M_{\text{gen}}(n) = G + \max_i s_i $$
where $G$ is the frame object, measured at 200 bytes, and only one item is alive at a time. $n$ does not appear. Memory is $O(1)$ in the length of the stream rather than $O(n)$.
The ratio for the container alone is:
$$ \frac{M_{\text{list}}}{M_{\text{gen}}} = \frac{444{,}376}{200} \approx 2{,}222\times $$
and unlike a speed ratio, this one is unbounded — double $n$ and it doubles, because the denominator is constant.
The honest limits on this argument matter as much as the argument:
It only holds if you consume incrementally. list(gen) materialises everything and you are back to $O(n)$. A generator feeding sorted() or len() saves nothing, because both must see every item.
It says nothing about speed. Generators have per-item overhead from resuming the frame — typically making them slightly slower than a list comprehension for small $n$ that fits comfortably in memory. The win is memory and time-to-first-item, not throughput.
Time to first item is the second quantity, and often the one users feel. A list must complete all $n$ items before returning anything: $T_{\text{first}} = n \cdot t_{\text{item}}$. A generator yields after one: $T_{\text{first}} = t_{\text{item}}$. This is why streaming a model's response feels immediate while buffering the full completion does not, even though total time is identical.
5. Real code
"""Generators, decorators, context managers, dataclasses -- what each one prevents."""
import functools
import sys
import time
from dataclasses import dataclass, field
# ---- 1. generators: constant memory over an unbounded stream ------------
def read_all(n):
"""Builds the whole list first. Memory grows with n."""
return [f"chunk-{i}" * 20 for i in range(n)]
def read_lazy(n):
"""Yields one at a time. Memory is constant, whatever n is."""
for i in range(n):
yield f"chunk-{i}" * 20
print("1. GENERATORS -- constant memory regardless of stream length")
eager = read_all(50_000)
lazy = read_lazy(50_000)
print(f" list of 50,000 chunks : {sys.getsizeof(eager):>9,} bytes (the container alone)")
print(f" generator : {sys.getsizeof(lazy):>9,} bytes")
print(" The generator holds ONE item at a time. This is why streaming a model's")
print(" output, or a large file, uses a generator and not a list.")
first_two = [next(lazy), next(lazy)]
print(f" pulled lazily, one at a time: {[c[:14] + '...' for c in first_two]}")
print(f" (each chunk is {len(first_two[0])} chars; the generator never held both)")
# ---- 2. decorators: cross-cutting behaviour, written once ---------------
def retry(times=3, delay=0.0):
"""Wrap a flaky call. The pattern every API client needs."""
def deco(fn):
@functools.wraps(fn) # keeps __name__ and the docstring
def wrapper(*args, **kwargs):
last = None
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as e: # noqa: BLE001 - demo
last = e
print(f" attempt {attempt} failed: {e}")
time.sleep(delay)
raise last
return wrapper
return deco
calls = {"n": 0}
@retry(times=3)
def flaky_api():
"""Fails twice, then succeeds."""
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError("timeout")
return "ok"
print("\n2. DECORATORS -- retry logic written once, applied by one line")
print(f" result: {flaky_api()!r} after {calls['n']} attempts")
print(f" functools.wraps preserved the name: {flaky_api.__name__!r}")
print(f" and the docstring: {flaky_api.__doc__!r}")
print(" WITHOUT @functools.wraps, both would say 'wrapper' -- which breaks")
print(" logging, tracebacks and anything that introspects the function.")
# ---- 3. context managers: cleanup that happens even on failure ----------
class Timer:
def __init__(self, label):
self.label = label
def __enter__(self):
self.t0 = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.ms = (time.perf_counter() - self.t0) * 1000
status = "raised " + exc_type.__name__ if exc_type else "ok"
print(f" [{self.label}] {self.ms:.1f} ms ({status})")
return False # do NOT swallow the exception
print("\n3. CONTEXT MANAGERS -- __exit__ runs even when the body raises")
with Timer("successful work"):
sum(range(200_000))
try:
with Timer("work that raises"):
raise ValueError("boom")
except ValueError:
print(" ...and the exception still propagated, as it must.")
print(" Returning False from __exit__ is what lets it propagate. Returning")
print(" True SWALLOWS it -- a silent-failure bug that is easy to write.")
# ---- 4. dataclasses: structure instead of dicts -------------------------
@dataclass
class RetrievalResult:
doc_id: str
score: float
tags: list[str] = field(default_factory=list) # NOT tags: list = []
print("\n4. DATACLASSES -- typed fields, free __repr__ and __eq__")
r1 = RetrievalResult("d1", 0.92)
r2 = RetrievalResult("d1", 0.92)
print(f" {r1}")
print(f" equality by VALUE: r1 == r2 -> {r1 == r2}")
r1.tags.append("policy")
print(f" r1.tags {r1.tags} r2.tags {r2.tags} <- separate lists")
print(" field(default_factory=list) is the dataclass fix for the mutable")
print(" default problem; a bare `tags: list = []` raises at class creation.")
assert sys.getsizeof(lazy) < sys.getsizeof(eager)
assert first_two[0].startswith("chunk-0") and len(first_two[0]) == 140
assert calls["n"] == 3 and flaky_api.__name__ == "flaky_api"
assert r1 == r2 or r1.tags != r2.tags # equal until one is mutated
assert r2.tags == [] and r1.tags == ["policy"]
print("\nall assertions passed")
Output:
1. GENERATORS -- constant memory regardless of stream length
list of 50,000 chunks : 444,376 bytes (the container alone)
generator : 200 bytes
The generator holds ONE item at a time. This is why streaming a model's
output, or a large file, uses a generator and not a list.
pulled lazily, one at a time: ['chunk-0chunk-0...', 'chunk-1chunk-1...']
(each chunk is 140 chars; the generator never held both)
2. DECORATORS -- retry logic written once, applied by one line
attempt 1 failed: timeout
attempt 2 failed: timeout
result: 'ok' after 3 attempts
functools.wraps preserved the name: 'flaky_api'
and the docstring: 'Fails twice, then succeeds.'
WITHOUT @functools.wraps, both would say 'wrapper' -- which breaks
logging, tracebacks and anything that introspects the function.
3. CONTEXT MANAGERS -- __exit__ runs even when the body raises
[successful work] 4.0 ms (ok)
[work that raises] 0.0 ms (raised ValueError)
...and the exception still propagated, as it must.
Returning False from __exit__ is what lets it propagate. Returning
True SWALLOWS it -- a silent-failure bug that is easy to write.
4. DATACLASSES -- typed fields, free __repr__ and __eq__
RetrievalResult(doc_id='d1', score=0.92, tags=[])
equality by VALUE: r1 == r2 -> True
r1.tags ['policy'] r2.tags [] <- separate lists
field(default_factory=list) is the dataclass fix for the mutable
default problem; a bare `tags: list = []` raises at class creation.
all assertions passed
Three details in that output deserve a second look.
444,376 bytes is the container alone. sys.getsizeof on a list reports the array of pointers, not the objects they point at. The 50,000 strings add roughly 9.5 MB on top. The generator's 200 bytes, by contrast, really is the whole cost — the frame plus whichever single string is currently alive. So the gap in the printed numbers understates the real one by more than an order of magnitude.
The failing Timer still printed. [work that raises] 0.0 ms (raised ValueError) appears before the exception reaches the except block. That ordering is the guarantee: __exit__ runs during unwinding, so your metric is recorded for failed requests too — which is precisely when you want it. A try/finally written by hand gets this right only if someone remembered to write it.
r1 == r2 is True for two separately-constructed objects. A plain class would return False there, because the default __eq__ is identity. Value equality is what makes dataclasses usable in tests, and the assertion r1 == r2 or r1.tags != r2.tags is written that way to hold both before and after the mutation on the following line.
6. Real-world example
An ingestion job that reads documents, embeds them, and writes them to a vector store. The version that works on the test fixture:
def ingest(path):
docs = load_all(path) # a list of every document
chunks = [c for d in docs for c in chunk(d)]
vectors = embed_batch([c.text for c in chunks])
store.upsert(list(zip(chunks, vectors)))
On a 200-document fixture this is clear and fast. On the real 400,000-document corpus it is killed by the OOM killer before it reaches embed_batch, because docs, chunks, and every intermediate list are alive simultaneously. The traceback, if you get one at all, points nowhere useful — the process simply dies.
The generator version streams, and processes in bounded batches:
def ingest(path, batch_size=256):
def chunks():
for doc in load_lazy(path): # yields, does not accumulate
yield from chunk(doc)
with Timer("ingest"), store.transaction():
for batch in batched(chunks(), batch_size):
vectors = embed_batch([c.text for c in batch])
store.upsert(list(zip(batch, vectors)))
Peak memory is now proportional to batch_size, not to the corpus. The same code handles 200 documents and 4 million. itertools.batched (3.12+) does the grouping; before that, a small helper does the same thing.
All four features are visible here and each is doing a job:
The generator (chunks()) is what makes the memory bound possible, and yield from flattens the nested loop without materialising anything.
The context managers — Timer and store.transaction() — mean the duration is recorded and the transaction is rolled back even if embed_batch raises on batch 900. Stacked in one with, they unwind in reverse order.
A decorator belongs on embed_batch: it is a network call to a rate-limited API, so @retry(times=3, delay=1.0) is exactly the cross-cutting concern decorators exist for. Written inline, that retry logic would be duplicated at every provider call site — see API Best Practices for the backoff policy it should implement.
A dataclass is what chunk should return. c.text reads correctly; c["text"] invites a typo that fails at runtime on batch 900, and a plain tuple gives you c[0], which nobody can review.
7. Interview questions companies actually ask
"What is the difference between a list comprehension and a generator expression?" Brackets versus parentheses; the list builds everything immediately in O(n) memory, the generator yields lazily in O(1). Strong answer names the catch: generators are single-use and lazy, so errors surface at consumption time, and if you call list() on one you have given up the entire benefit.
"Why do decorators need functools.wraps?" Without it, the returned wrapper carries its own __name__, __doc__, and __module__, so introspection — logging, docs, test collection, framework registries — sees wrapper instead of the real function. wraps copies the metadata and sets __wrapped__.
"What happens if __exit__ returns True?" The exception is suppressed and execution continues after the with block. This is almost always a bug; contextlib.suppress is the one legitimate use. Strong answer: a bare return (returning None) is correct, and the danger is an explicit return True added by someone trying to "handle" errors.
"When would you use a dataclass over a dict?" When the shape is known and fixed. You get typed fields a checker can verify, autocomplete, a readable repr, value equality, and attribute access that fails loudly on a typo rather than returning None. Use a dict when keys are genuinely dynamic.
"Why does field(default_factory=list) exist?" Class-body defaults are evaluated once, so a mutable default would be shared by every instance — the mutable-default-argument bug at class scope. Dataclasses refuse to compile a mutable default and require a factory that is called per instance.
"Write a decorator that retries with exponential backoff." They want the two-layer closure for the arguments, functools.wraps, a narrow except, 2 ** attempt sleeps with jitter, and re-raising the last exception rather than returning None on final failure. The last point is the one most candidates miss.
8. When to use / tradeoffs
Generators — use whenever the collection is large, unbounded, or comes off the network, and whenever time-to-first-item matters. Do not use when you need len(), random access, or more than one pass; convert to a list once and reuse it instead of regenerating. The cost is debuggability: you cannot print a generator's contents without consuming it, and a lazy pipeline makes stack traces harder to read because the error surfaces far from where the generator was defined.
Decorators — use for cross-cutting concerns that do not change what the function means: retry, timing, caching (functools.lru_cache), auth, rate limiting. Avoid stacking more than two or three; the order matters and is not obvious to readers. The real cost is action at a distance — behaviour that is invisible at the call site — so a decorator that could plausibly surprise someone reading the call needs to be named unmistakably.
Context managers — use for anything with a paired setup and teardown: files, locks, transactions, timers, temporary configuration. There is essentially no downside; the only trap is __exit__ returning truthy. Prefer @contextlib.contextmanager for simple cases and the class form when the object needs state or methods.
Dataclasses — use for structured data with a known shape. Add frozen=True when instances must be hashable or shared across threads, slots=True when you have millions of them. Reach for Pydantic instead when the data crosses a trust boundary and needs validation and coercion rather than just structure, which is the usual choice for API request bodies — see Structured Outputs. Plain dict remains right for genuinely dynamic keys and for pass-through JSON you never inspect.
The connecting tradeoff is that all four add indirection. A generator pipeline, a stack of decorators, and a nest of context managers can produce code where no single function shows what actually happens. Each is justified by a concrete failure it prevents — an OOM kill, duplicated retry logic, a leaked transaction, a shared mutable default. Reach for them when you have that failure, not to demonstrate that you know them.
9. Summary + related articles
Generators make memory constant in stream length, which is the difference between a job that scales and one the OOM killer stops. Decorators make cross-cutting behaviour writable once, provided functools.wraps keeps the function's identity intact. Context managers make cleanup unconditional, and the one rule to remember is that __exit__ must not return True. Dataclasses replace shapeless dicts with typed fields, and are the one place the language will refuse a mutable default rather than silently share it.
None of these is syntactic sugar. Each maps to a specific production failure — memory exhaustion, log entries that all say wrapper, a transaction left open by an exception, state leaking between instances. That is the reason to learn them past the point of recognising the syntax.
Related articles
- Python Fundamentals — the mutable-default bug at function scope, which
default_factoryfixes at class scope - Python Data Structures — what a generator should feed, and when a dataclass beats a tuple
- Python Best Practices — type hints on generator returns, and testing lazy code
- Async Programming — the other suspending-function feature, and the blocking call that serialises a service
- Structured Outputs — dataclasses versus Pydantic when data crosses a trust boundary
- API Best Practices — the backoff policy the
@retrydecorator should implement
Resources
- Python documentation, "Generators" and the
yieldexpression reference — the precise semantics of suspension and resumption: https://docs.python.org/3/reference/expressions.html#yieldexpr functoolsdocumentation —wraps,lru_cache,cached_property,partial: https://docs.python.org/3/library/functools.htmlcontextlibdocumentation —contextmanager,suppress,ExitStackfor a dynamic number of managers: https://docs.python.org/3/library/contextlib.htmldataclassesdocumentation —field,frozen,slots,order, and the mutable-default rule: https://docs.python.org/3/library/dataclasses.html- PEP 255 (simple generators) and PEP 380 (
yield from) — the design rationale, still the clearest explanation of delegation: https://peps.python.org/pep-0380/ - Beazley, D. — Generators: The Final Frontier (PyCon 2014) — the definitive deep treatment; long, and worth it: https://www.dabeaz.com/finalgenerator/
- Ramalho, L. — Fluent Python (2nd ed.), Ch. 17 (iterators and generators) and Ch. 9 (decorators and closures)
itertoolsdocumentation —islice,chain,groupby,batched; the standard toolkit for composing generators without materialising them: https://docs.python.org/3/library/itertools.html