You have met a decorator already, even if you have never written one. @property, @staticmethod, @dataclass, @pytest.fixture, @app.route("/users") — every framework you will touch is held together by them, and every one of them is the same three ideas stacked on top of each other:
def timing(func): # 1. a function that takes a function
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # 2. a closure remembering `func`
print(f"{func.__name__}: {(time.perf_counter() - start) * 1000:.1f} ms")
return result
return wrapper # 3. and returns the new function
@timing # ...and `@` is just `slow = timing(slow)`
def slow():
...
That is the whole trick. There is no compiler magic, no registry, no framework — a decorator is an ordinary function that takes a function and gives you a different one, and @ is a two-character shorthand for an assignment you could type yourself.
This lesson builds that from the bottom up. First the classic functional trio (map, filter, reduce) and the honest story about when to use them — which, on Python 3.12, is less often than the internet will tell you. Then closures, the mechanism that lets wrapper remember func after timing has already returned, which we will prove by printing the actual cell object that holds it. Then decorators, built from nothing, up to @retry(times=3). And along the way the sharpest edges in the language: [2, 2, 2] when you expected [0, 1, 2], and a registry that silently keeps one function out of two.
Why this matters
Python is not a functional language, and pretending otherwise produces code your colleagues quietly rewrite. But it is a language where functions are objects — you can name them, store them in lists, pass them around, and return new ones — and that single fact is the foundation under decorators, callbacks, sorted(key=...), caching, dependency injection, and most of what makes modern Python frameworks feel effortless. First-class functions and lambdas established that groundwork. This lesson is what you build on top of it.
Practically, it matters because decorators are the most common place a beginner’s mental model breaks silently. Not loudly — silently. You add a decorator, your tests still pass, and three weeks later your API docs are full of functions called wrapper that all take (*args, **kwargs). Or you write a loop that builds handlers and every one behaves like the last. Neither raises an exception. Both are closure mechanics you cannot debug without knowing what a cell object is.
So the through-line is one sentence: a function carries its environment with it. When timing returns wrapper, the local variable func should have died with timing’s stack frame — that is how local variables work everywhere else. It does not die. Python noticed an inner function referenced it, and put it somewhere that outlives the call. Understanding where explains decorators, the late-binding trap, nonlocal, and why @lru_cache on a method quietly holds your objects in memory forever.
One matter of taste, up front, because this lesson is blunt about it: functional tools in Python are a means, not an aesthetic. reduce(lambda a, b: a + b, nums) is not clever; sum(nums) is 7× faster and reads like English. The style earns its place where it genuinely wins — a decorator that adds retry logic to twenty functions without touching one of them — and loses everywhere it is imported from Haskell wholesale. Knowing the difference is the actual skill.
map, filter and the honest comparison
They return lazy iterators, not lists
This is the first thing that surprises people coming from Python 2, tutorials written for Python 2, or any other language:
nums = [1, 2, 3, 4]
print(map(str, nums)) # => <map object at 0x104c75ae0>
print(filter(lambda n: n % 2 == 0, nums)) # => <filter object at 0x104c75f00>
No list. In Python 3, map and filter return iterators — objects that compute values one at a time, on demand, and remember nothing. You must wrap them in list() to see anything:
print(list(map(str, nums))) # => ['1', '2', '3', '4']
print(list(filter(lambda n: n % 2 == 0, nums))) # => [2, 4]
Laziness is easy to state and easy to underestimate, so watch it happen:
def loud(n):
print(f" computing {n}")
return n * 2
r = map(loud, [1, 2, 3])
print("map created, nothing ran yet")
print(next(r))
print(next(r))
print(list(r))
map created, nothing ran yet
computing 1
2
computing 2
4
computing 3
[6]
Creating the map ran zero function calls. Each next() pulled exactly one. That is a real feature: map over a 50 GB file processes one line at a time in constant memory. It is also the source of the trio’s nastiest bug.
The one-shot trap
An iterator is consumed as you read it, and it does not rewind:
m = map(str, [1, 2, 3, 4])
print(list(m)) # => ['1', '2', '3', '4']
print(list(m)) # => [] <- exhausted, and NO error
The second list(m) returns an empty list. Not an exception — an empty list, because an exhausted iterator is indistinguishable from an empty one. This is the classic “the report ran fine but the second half is blank” bug: you iterate a map once for a count, then again for the rows, and the rows are gone. Need the data twice? Materialise it once with list().
Worse, an iterator is always truthy, so the obvious guard does not guard:
empty = filter(lambda x: x > 100, [1, 2, 3])
if empty: # ALWAYS true — it's an object, not a result
print("truthy! even though it yields nothing:", list(empty))
# => truthy! even though it yields nothing: []
if empty: asks the filter object whether it is truthy, and with no __len__ and no __bool__, every object in Python defaults to True. It has not run yet — it cannot know whether it will yield anything. Materialise first: results = list(filter(...)), then test if results:.
The same shape catches len() and indexing:
len(map(str, nums)) # TypeError: object of type 'map' has no len()
map(str, nums)[0] # TypeError: 'map' object is not subscriptable
An iterator does not know how many items it will produce until it produces them, so len() is genuinely unanswerable. These two tracebacks are the most common way people discover map is not a list.
| Behaviour | map / filter in Python 3 |
In Python 2 (and most tutorials) |
|---|---|---|
| Return type | map / filter object (an iterator) |
list |
| Evaluation | Lazy — nothing runs until you iterate | Eager — everything runs immediately |
| Memory for 10M items | O(1) — one item at a time | O(n) — the whole list resident |
len(x) |
TypeError: object of type 'map' has no len() |
Works |
x[0] |
TypeError: 'map' object is not subscriptable |
Works |
| Iterate twice | Second pass yields nothing — silently | Works, it’s a list |
bool(x) |
Always True, even when it yields nothing |
False when empty |
print(x) |
<map object at 0x...> |
The actual list |
The signatures
| Call | Signature | Returns | Notes |
|---|---|---|---|
map(func, iterable) |
one function, one iterable | lazy iterator | func takes 1 argument |
map(func, it1, it2, ...) |
one function, n iterables | lazy iterator | func takes n arguments; stops at the shortest |
filter(pred, iterable) |
predicate returning truthy/falsy | lazy iterator | Keeps items where pred(x) is truthy |
filter(None, iterable) |
literally None |
lazy iterator | Special case: keeps items that are themselves truthy |
functools.reduce(func, it) |
func(acc, x) |
a single value | TypeError on an empty iterable |
functools.reduce(func, it, initial) |
func(acc, x) + a start value |
a single value | Returns initial on an empty iterable |
itertools.filterfalse(pred, it) |
predicate | lazy iterator | The inverse of filter — keeps the falsy |
itertools.starmap(func, it) |
func(*args) |
lazy iterator | For an iterable of argument tuples |
Two of those rows deserve a demonstration. Multiple iterables zip themselves together and stop at the shortest, silently:
print(list(map(lambda a, b: a + b, [1, 2, 3], [10, 20, 30]))) # => [11, 22, 33]
print(list(map(lambda a, b: a + b, [1, 2, 3], [10, 20]))) # => [11, 22] <- 3 dropped
print(list(map(pow, [2, 3, 4], [3, 2, 1]))) # => [8, 9, 4]
That middle line is a real bug factory — mismatched lengths do not raise; the extra items just never appear. (zip(..., strict=True) exists for exactly this reason since 3.10; map has no equivalent.)
And filter(None, ...) is a genuinely useful special case — pass the literal None instead of a predicate and it keeps whatever is truthy:
vals = [0, 1, "", "a", None, [], [1], False, True, 0.0]
print(list(filter(None, vals))) # => [1, 'a', [1], True]
Every falsy value is dropped. It is the tersest way to strip empties, and one of the few places filter is unambiguously the nicest tool available.
reduce: the one that got exiled
reduce takes a two-argument function and folds an iterable down to a single value, carrying an accumulator left to right:
from functools import reduce
import operator
print(reduce(operator.add, [1, 2, 3, 4])) # => 10
print(reduce(operator.mul, [1, 2, 3, 4])) # => 24
print(reduce(lambda a, b: a + b, [1, 2, 3, 4], 100)) # => 110 (100 is the initial)
Print the steps and the mechanism is obvious — there is no recursion, just a loop with a running variable:
def show(acc, x):
print(f" acc={acc!r} x={x!r} -> {acc + x!r}")
return acc + x
reduce(show, ["a", "b", "c"])
acc='a' x='b' -> 'ab'
acc='ab' x='c' -> 'abc'
Note that with no initial, the first element becomes the accumulator and the function is called n−1 times — so on a single-element iterable it is never called at all, and reduce(never, [42]) returns 42 without running never.
The empty-sequence trap
reduce(operator.add, [])
# TypeError: reduce() of empty iterable with no initial value
With nothing to seed the accumulator and no initial, reduce has no possible answer, so it raises. This is a genuinely common production failure: your code works all year and dies on the day a filter legitimately matches nothing. Always pass initial unless you can prove the iterable is non-empty:
print(reduce(operator.add, [], 0)) # => 0
The initial argument is not just an empty-guard — it also fixes the type of the result. reduce(operator.add, [], 0) gives you an int; without it there is no answer at all.
Why it left the builtins
reduce was a builtin in Python 2. In Python 3 it moved to functools, and Guido van Rossum’s rationale was readability, not performance: outside + and *, almost nobody can read a reduce at a glance and say what it does — you have to mentally simulate the fold. Compare:
total = reduce(lambda a, b: a + b, nums) # ...adds them up. Probably.
total = sum(nums) # adds them up.
The move was a deliberate nudge, and the numbers back it up. Measured on CPython 3.12.3 (macOS, arm64), timeit, best of 12, 1,000 items:
| Expression | Time | vs sum |
|---|---|---|
sum(data) |
2.5 µs | baseline |
reduce(operator.add, data) |
17.5 µs | 7.0× slower |
reduce(lambda a, b: a + b, data) |
33.9 µs | 13.6× slower |
sum is a C loop that never re-enters the interpreter; reduce makes a full Python-level call per element, and a lambda makes it worse. The rule is easy: there is a specialised builtin for almost every fold you actually want, and it is both faster and clearer.
Instead of this reduce… |
Write this | Why |
|---|---|---|
reduce(operator.add, nums) |
sum(nums) |
7× faster, obvious |
reduce(operator.mul, nums) |
math.prod(nums) |
Builtin since 3.8 |
reduce(lambda a, b: a or b, flags) |
any(flags) |
Short-circuits; reduce cannot |
reduce(lambda a, b: a and b, flags) |
all(flags) |
Short-circuits |
reduce(lambda a, b: a if a > b else b, nums) |
max(nums) |
Handles key= and empties properly |
reduce(lambda a, b: a + [f(b)], xs, []) |
[f(x) for x in xs] |
The reduce is O(n²) — it copies the list every step |
reduce(operator.concat, list_of_lists) |
list(chain.from_iterable(lls)) |
O(n) instead of O(n²) |
reduce(operator.or_, dicts, {}) |
d = {} + a loop of d.update(x) |
Clearer; same result |
| A running total you want to keep | itertools.accumulate(nums) |
Gives every intermediate, lazily |
| Genuinely custom, non-associative folds | reduce — or a plain loop |
The honest remaining use |
Two rows are worth pausing on. any/all short-circuit — they stop at the first decisive element — while reduce always visits every item, so on a million-element list decided at index 3, reduce does 999,997 pointless calls. And the list-building row hides a complexity bug: acc + [x] builds a new list every iteration, making the fold quadratic. On 10,000 items that is 50 million copies to do what a comprehension does in one pass.
The accumulate row is the underrated cousin — reduce that shows its working, lazily. list(accumulate([1, 2, 3, 4])) gives [1, 3, 6, 10], which is your running totals, cumulative balances and rolling maxima solved.
So when does reduce genuinely earn its import? When the fold is custom, associative, and has no builtin — merging dicts with operator.or_, intersecting a list of sets, composing a chain of transformations:
dicts = [{"a": 1}, {"b": 2}, {"a": 9, "c": 3}]
print(reduce(operator.or_, dicts, {})) # => {'a': 9, 'b': 2, 'c': 3}
That is defensible. Even there, a three-line loop is defensible too — and if a reader has to pause, the loop wins.
Comprehension vs map/filter: the measured answer
Here is where a lot of Python advice is confidently, provably out of date.
The comprehensions lesson measured this properly, and the headline inverts a decade of blog posts: on Python 3.12, comprehensions beat map. The cause is PEP 709, which landed in 3.12 and inlines list, dict and set comprehensions into the enclosing function — no throwaway function object, no frame push or pop per comprehension. (You can see it in a traceback: 3.11 and earlier show a <listcomp> frame, 3.12 does not.)
Reproduced independently here on CPython 3.12.3 (macOS, arm64), timeit, best of 12, 1,000 items:
| Expression | 3.12 | Verdict |
|---|---|---|
[str(n) for n in data] |
42.4 µs | Comprehension wins |
list(map(str, data)) |
60.4 µs | ~42% slower — on 3.9 this was the fast one |
[n*n for n in data] |
17.4 µs | Comprehension wins |
list(map(lambda n: n*n, data)) |
33.5 µs | ~1.9× slower — a lambda was always slower |
[n for n in data if n % 2 == 0] |
19.9 µs | Comprehension wins |
list(filter(lambda n: n % 2 == 0, data)) |
33.7 µs | ~1.7× slower |
[n*n for n in data if n % 2 == 0] |
25.4 µs | One readable expression |
list(map(lambda n: n*n, filter(lambda n: n % 2 == 0, data))) |
49.7 µs | ~2× slower and harder to read |
On Python 3.9 the first two rows were the other way round (list(map(str, data)) 69.7 µs versus [str(n) for n in data] 81.3 µs) — exactly where the folklore came from. The advice was not wrong when it was written; the language changed underneath it. Any performance claim that does not name a version has a shelf life.
So the decision is now mostly about readability, and the honest table looks like this:
| Situation | Reach for | Why |
|---|---|---|
| Transform every item | Comprehension | [f(x) for x in xs] — faster on 3.12 and reads left to right |
| Filter items | Comprehension | [x for x in xs if p(x)] — one expression, no lambda |
| Transform and filter | Comprehension | map(f, filter(p, xs)) is 2× slower and reads inside-out |
The function is a lambda |
Comprehension, always | map(lambda...) has never won on any version |
| Applying an existing named function lazily | map(f, xs) |
Genuinely tidy: map(int, line.split()) |
| Streaming a huge/infinite source | map/filter or a genexpr |
Both are O(1) memory; pick on readability |
| Feeding a C-level consumer | map(f, xs) |
sum(map(int, lines)), max(map(len, words)) — no list built |
| Multiple parallel iterables | map(f, xs, ys) |
The one thing a comprehension can’t do without zip |
| Stripping falsy values | filter(None, xs) |
Terse, no lambda, unambiguous |
| Folding to one value | A builtin (sum/any/max) |
Never reduce if a builtin exists |
| A team that doesn’t know FP | Comprehension / loop | Code is read more than written |
The summary a reviewer will thank you for: default to a comprehension; use map when passing an already-named function and building no list. sum(map(int, lines)) is lovely. list(map(lambda x: x * 2, xs)) is a comprehension wearing a disguise.
Closures: a function that remembers
Now the mechanism that makes decorators possible.
def multiplier(factor):
def multiply(n):
return n * factor # `factor` is not local, and not global. What is it?
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(10), triple(10)) # => 20 30
Stop and notice what should be impossible. multiplier(2) returned. Its stack frame is gone. Its local variable factor should have died with it — that is what “local” means. Yet double(10) still knows factor is 2, and triple simultaneously knows it is 3. Two functions, built from one def, each remembering a different value.
A function bundled with the enclosing variables it captured is a closure. And Python does not do this by magic or by copying — it does it with a real, printable object.
Proving it: cells and __closure__
print(double.__closure__) # => (<cell at 0x104b1f580: int object at 0x106189c68>,)
print(double.__closure__[0].cell_contents) # => 2
print(triple.__closure__[0].cell_contents) # => 3
print(double.__code__.co_freevars) # => ('factor',)
print(multiplier.__code__.co_cellvars) # => ('factor',)
There it is. double.__closure__ is a tuple of cell objects, and cell_contents holds the captured value. When Python compiles multiplier, it sees the inner function reference factor, so it does not store factor as an ordinary local at all — it allocates a cell, a small heap-allocated box, and both the frame and the inner function point at it. The frame dies; the cell does not, because the function object still references it.
A function that captures nothing has no closure at all:
def plain(n):
return n * 2
print(plain.__closure__) # => None
print(plain.__code__.co_freevars) # => ()
None, not an empty tuple. __closure__ being None is the definitive test for “this function captured nothing.”
| Attribute | On a closure | On a plain function | What it tells you |
|---|---|---|---|
f.__closure__ |
tuple of cell objects |
None |
Whether it captured anything at all |
f.__closure__[i].cell_contents |
the captured value | — | ⚠️ ValueError: Cell is empty if not yet assigned |
f.__code__.co_freevars |
('factor',) |
() |
The names captured, in __closure__ order |
outer.__code__.co_cellvars |
('factor',) |
() |
Names the enclosing function had to put in cells |
f.__defaults__ |
(2,) |
None |
Default args — the other way to capture (see late binding) |
f.__globals__ |
the module dict | same | Globals are not captured; looked up live at call time |
f.__name__ |
'multiply' |
'plain' |
What functools.wraps exists to preserve |
Cells are shared and live
The most important property: a cell is not a snapshot. It is a live reference, and sibling closures share it:
def make_pair():
x = 0
def get():
return x
def bump():
nonlocal x
x += 1
return get, bump
get, bump = make_pair()
print(get()) # => 0
bump(); bump()
print(get()) # => 2 get() sees bump()'s writes
print(get.__closure__[0] is bump.__closure__[0]) # => True literally the same cell
get and bump were compiled from different def statements and hold the same cell object. Mutating through one is visible through the other — remember this, it is the entire explanation of the late-binding trap.
nonlocal vs global
Reading an enclosing variable just works. Writing to one does not:
def counter_broken():
count = 0
def inc():
count += 1 # looks innocent
return count
return inc
counter_broken()()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Version note: that is the Python 3.11+ wording. On 3.9/3.10 the same bug reads
UnboundLocalError: local variable 'count' referenced before assignment— same cause, terser message.
The rule that explains it: assignment anywhere in a function body makes that name local to the whole function, decided at compile time, before a single line runs. count += 1 is an assignment, so count is local to inc — and reading it on the right-hand side of += then finds it unassigned. Note the asymmetry that makes this confusing: pure reads are fine, because with no assignment there is nothing to make the name local.
def reader():
msg = "hello"
def show():
return msg.upper() # read-only — works fine, no nonlocal needed
return show
print(reader()()) # => HELLO
nonlocal is the fix — it tells the compiler: this name lives in an enclosing function scope; bind to that cell, do not create a local.
def counter(start=0):
count = start
def inc(step=1):
nonlocal count
count += step
return count
return inc
c = counter()
print(c(), c(), c(10)) # => 1 2 12
local (default) |
nonlocal |
global |
|
|---|---|---|---|
| Binds to | A new name in this function | The nearest enclosing function’s variable | A module-level variable |
| Skips | — | Module scope entirely | All enclosing functions |
| Needs to already exist? | No | Yes — else SyntaxError at compile time |
No — creates it on assignment |
| At module level | n/a | SyntaxError: nonlocal declaration not allowed at module level |
Legal but a no-op |
| Typical use | Everything | Counters/accumulators in a closure; decorator state | Rare — usually a design smell |
| Read without it | n/a | Works — reads need no declaration | Works — reads need no declaration |
| Write without it | n/a | UnboundLocalError |
UnboundLocalError |
nonlocal fails loudly if there is nothing to bind to, and at compile time — so you find out on import, not in production:
def outer():
def inner():
nonlocal nope # there is no `nope` in any enclosing function
nope = 1
return inner
# SyntaxError: no binding for nonlocal 'nope' found
The distinction to hold: global reaches all the way out to the module; nonlocal reaches out exactly one function layer at a time (the nearest enclosing scope with that name). They are not interchangeable, and nonlocal will never touch a module-level variable.
The late-binding trap
The most famous closure bug in Python, and it returns wrong data without raising a thing:
def build_handlers():
handlers = []
for code in (200, 404, 500):
handlers.append(lambda: f"handling {code}")
return handlers
print([h() for h in build_handlers()])
# => ['handling 500', 'handling 500', 'handling 500']
Three lambdas, all behaving like the last. No traceback, no warning — three identical wrong answers. The cause is exactly the shared-cell property from two sections ago:
broken = build_handlers()
print(broken[0].__closure__[0] is broken[1].__closure__[0]) # => True ONE cell
print(broken[0].__closure__[0].cell_contents) # => 500
The loop did not create three variables. It created one variable code, rebound it three times, and all three lambdas captured the same cell. The lambda body says “look up code” — and does that lookup when called, long after the loop finished and left 500 in the cell. That is late binding: the closure captures the variable, not its value.
This is not a lambda bug or a comprehension bug. It is a scoping rule, and it shows up in four subtly different shapes:
| Where the loop runs | __closure__ |
Result | Why |
|---|---|---|---|
for loop inside a function |
(<cell ...>,), shared |
[2, 2, 2] |
Classic closure late binding |
for loop at module level |
None |
[2, 2, 2] |
i is a global — no cell at all, but looked up late all the same |
Comprehension [lambda: i for i in range(3)] |
(<cell ...>,), shared |
[2, 2, 2] |
The comprehension’s own scope, cell shared across all three |
Default-arg fix [lambda i=i: i ...] |
None |
[0, 1, 2] |
Captured nothing; the value is in __defaults__ |
That second row is the one nobody expects, and it clarifies everything: at module level there is no closure whatsoever, yet the bug is identical. Late binding is not really “a closure problem” — it is a name-lookup-at-call-time problem. Closures are just where you meet it most.
The fixes
The default-argument fix is the idiom — default arguments are evaluated once, at def time, so they snapshot the value:
def build_fixed():
handlers = []
for code in (200, 404, 500):
handlers.append(lambda code=code: f"handling {code}")
return handlers
fixed = build_fixed()
print([h() for h in fixed]) # => ['handling 200', 'handling 404', 'handling 500']
print(fixed[0].__closure__) # => None nothing captured!
print([h.__defaults__ for h in fixed]) # => [(200,), (404,), (500,)] snapshotted
__closure__ is now None — the fix works by not closing over anything. The value went into __defaults__ at definition time, and the same “defaults are evaluated once” rule that causes the infamous def f(x=[]) bug is here doing exactly what we want. The cost is honest: code is now a parameter a caller can override (h(999) works) — a non-issue for a throwaway callback, a real smell in a public API.
The factory fix is more explicit — a new call means a new frame means a genuinely new cell:
def build_factory():
def make(code):
return lambda: f"handling {code}"
return [make(c) for c in (200, 404, 500)]
fac = build_factory()
print([h() for h in fac]) # => ['handling 200', 'handling 404', 'handling 500']
print([h.__closure__[0].cell_contents for h in fac]) # => [200, 404, 500] three cells
Three separate cells, one per call. Reach for this when the closure is part of an API and you do not want a fake parameter hanging off the signature.
| Fix | Mechanism | __closure__ |
Trade-off |
|---|---|---|---|
lambda i=i: ... |
Default arg evaluated at def time |
None |
Idiomatic and terse; adds an overridable parameter |
def make(i): return lambda: i |
New call → new frame → new cell | one cell each | Explicit, no fake parameter; more lines |
functools.partial(f, i) |
Argument bound into the partial object | None |
Clean when f already exists; partial has a great repr |
A class with __init__ |
Value stored on self |
n/a | Best when there is state and behaviour |
itertools.repeat / bind in a comprehension |
— | — | ❌ Doesn’t help — same cell |
Closures as lightweight objects
A closure is state plus behaviour that outlives a call. So is an object. They are more alike than they look:
def make_accumulator():
total = 0
def add(x):
nonlocal total
total += x
return total
return add
acc = make_accumulator()
print(acc(10), acc(20), acc(5)) # => 10 30 35
acc2 = make_accumulator()
print(acc2(1)) # => 1 a completely independent total
That is a stateful object in five lines, with genuinely private state — real privacy, which Python’s _underscore convention only pretends at:
acc.total # AttributeError: 'function' object has no attribute 'total'
But now the honest comparison, because the closure stops being the right answer fast:
class Accumulator:
def __init__(self):
self.total = 0
def __call__(self, x):
self.total += x
return self.total
def reset(self):
self.total = 0
def __repr__(self):
return f"Accumulator(total={self.total!r})"
a = Accumulator()
print(a(10), a(20)) # => 10 30
print(a) # => Accumulator(total=30)
a.reset()
print(a) # => Accumulator(total=0)
The class version is longer, and better the moment you want a second operation. The closure cannot be reset without another returned function, inspected without __closure__[0].cell_contents, pickled, or subclassed — and it prints as <function make_accumulator.<locals>.add at 0x104b2c860>. The classes and objects lesson is where that path leads.
| Use a closure when | Use a class when |
|---|---|
| One function, one job (a callback, a key function) | You need two or more operations on the same state |
| The state is genuinely private and small | The state should be inspectable, or is more than a couple of fields |
| You are writing a decorator | You need __repr__, ==, pickling, subclassing |
The factory pattern reads naturally: multiplier(2) |
You want attributes, properties, or type hints on the state |
| It is throwaway and local | It is a public API others import |
| Rule of thumb: one method → closure | Rule of thumb: two+ methods → class |
The rule of thumb at the bottom is the whole section: a closure is a class with exactly one method. Returning a tuple of functions that share a cell means you have hand-rolled an object, badly. Write the class.
Decorators, from first principles
Everything so far was setup. Here is the payoff.
A decorator is a function that takes a function and returns a function. That is the entire definition. Write one with no @ anywhere:
def shout(func):
def wrapper(name):
return func(name).upper() + "!"
return wrapper
def greet(name):
return f"hello {name}"
greet = shout(greet) # <- the rebinding, done by hand
print(greet("vinod")) # => HELLO VINOD!
Read greet = shout(greet) carefully, because it is the whole concept. shout(greet) returns wrapper, and we assign that to the name greet. The original function still exists — wrapper holds it in a cell — but nothing points at it any more except the closure. The name now refers to a different function.
And that is precisely, mechanically, what @ does:
@shout
def greet(name):
return f"hello {name}"
print(greet("vinod")) # => HELLO VINOD!
@shout above def greet means exactly greet = shout(greet). No more, no less. If you remember one sentence from this lesson, that is the one.
It runs at definition time
A decorator is not a hook that fires when the function is called. It is an assignment that runs when the def is executed — at import:
def noisy(func):
print(f" [decorating {func.__name__} — this runs at import time]")
def wrapper(*a, **k):
return func(*a, **k)
return wrapper
@noisy
def never_called():
return 1
print("module body done; never_called was decorated but not called")
[decorating never_called — this runs at import time]
module body done; never_called was decorated but not called
The decorator body ran; the function never did. This is how @app.route("/users") registers a URL without anyone calling your view, and how @pytest.fixture gets discovered. Decoration is a side effect of importing.
*args, **kwargs: wrapping any function
shout only wraps single-argument functions. A real decorator must not care about the signature — accept everything, pass it straight through:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f" -> {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f" <- {func.__name__} returned {result!r}")
return result # <- the line everyone forgets
return wrapper
@log_calls
def add(a, b=0):
return a + b
print(add(2, b=3))
-> add(args=(2,), kwargs={'b': 3})
<- add returned 5
5
*args, **kwargs in the definition collects everything; func(*args, **kwargs) at the call site unpacks it back (parameters and arguments covers that pair in depth). The combination makes wrapper signature-agnostic — it can wrap anything.
And note that return. Miss it and you get the most common decorator bug in existence:
def forgetful(func):
def wrapper(*a, **k):
func(*a, **k) # calls it... and throws the result away
return wrapper
@forgetful
def gives():
return 42
print(gives()) # => None
No error. Every decorated function in your codebase silently starts returning None. A wrapper that does not return is a wrapper that deletes your data.
The diagram
The picture below is the whole model, and it reads left to right in the order things actually happen: the @ lines run once at import and rebind the name to a wrapper closure holding the original in its cell; a later call then flows caller → wrapper’s pre-code → the original body → wrapper’s post-code → back to the caller.
The six badges mark where people lose the thread: @ is an assignment that runs at import (1); a stacked pair is applied bottom-up (2); without @wraps the rebound name forgets itself (3); execution runs top-down (4); the exception path is why *args, **kwargs must pass straight through (5); and a wrapper that forgets to return yields None (6).
functools.wraps: not optional
Decorate a function and look at what you have:
def bare(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bare
def charge(amount: int, currency: str = "INR") -> str:
"""Charge the customer."""
return f"{currency} {amount}"
import inspect
print(charge.__name__) # => wrapper
print(charge.__doc__) # => None
print(charge.__qualname__) # => bare.<locals>.wrapper
print(inspect.signature(charge)) # => (*args, **kwargs)
print(charge.__annotations__) # => {}
Your function is gone. The name is wrapper, the docstring evaporated, the signature is a lie, and the type hints are gone. This is not cosmetic — help() shows wrapper(*args, **kwargs) with no docs; Sphinx generates an API reference where every function is called wrapper; pytest collects wrapper instead of test_thing; debuggers and tracebacks name wrapper, so a stack trace tells you nothing about what failed; and inspect.signature — which FastAPI, Click, Typer and pydantic use to do their job — sees (*args, **kwargs) and cannot route, validate, or build a CLI.
functools.wraps is a decorator for your wrapper that copies the identity across:
from functools import wraps
def good(func):
@wraps(func) # <- the one line
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good
def charge(amount: int, currency: str = "INR") -> str:
"""Charge the customer."""
return f"{currency} {amount}"
print(charge.__name__) # => charge
print(charge.__doc__) # => Charge the customer.
print(inspect.signature(charge)) # => (amount: int, currency: str = 'INR') -> str
print(charge.__annotations__) # => {'amount': <class 'int'>, 'currency': <class 'str'>, 'return': <class 'str'>}
print(charge.__wrapped__) # => <function charge at 0x102d77f60>
| Attribute | Without @wraps |
With @wraps |
|---|---|---|
__name__ |
'wrapper' |
'charge' |
__doc__ |
None (or the wrapper’s docstring — worse) |
'Charge the customer.' |
__qualname__ |
'bare.<locals>.wrapper' |
'charge' |
__module__ |
the decorator’s module | the function’s real module |
inspect.signature(f) |
(*args, **kwargs) |
(amount: int, currency: str = 'INR') -> str |
__annotations__ |
{} |
the real hints |
__wrapped__ |
absent | the original function |
inspect.getsource(f) |
the wrapper’s source | the wrapper’s source (⚠️ still not fixed) |
@wraps copies a specific list, and you can print it:
import functools
print(functools.WRAPPER_ASSIGNMENTS)
# => ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__', '__type_params__')
print(functools.WRAPPER_UPDATES)
# => ('__dict__',)
Version note:
__type_params__is new in 3.12 (PEP 695 generics). On 3.9 the tuple is('__module__', '__name__', '__qualname__', '__doc__', '__annotations__').
It assigns those attributes, updates __dict__ (so attributes other decorators attached survive), and sets __wrapped__ to the original — which is how inspect.signature recovers the real signature, and how you can drill back to the undecorated function.
Why it actually bites
The abstract argument (“introspection breaks”) never convinces anyone. This does. Any registry keyed on __name__ — and there are thousands, in every plugin system, CLI framework and task queue — collapses:
registry = {}
def register(f):
registry[f.__name__] = f
return f
@register
@bare
def handler_a():
return "a"
@register
@bare
def handler_b():
return "b"
print(list(registry.keys())) # => ['wrapper'] TWO functions, ONE key
print(registry["wrapper"]()) # => b handler_a is unreachable
Two functions registered. One survives. handler_a is silently gone, because both wrappers are named wrapper and the second overwrote the first — a route or task that simply never runs, with no exception and no warning. Add @wraps and the keys become ['handler_a', 'handler_b'].
Put @wraps(func) on every wrapper you ever write. One line, no downside, and forgetting it is the difference between a decorator and a landmine.
Decorators with arguments: the three-level nest
@retry is fine until you want @retry(times=5). That needs one more layer, and the reason is pure substitution:
@retrymeansf = retry(f)— soretryreceives the function.@retry(times=5)meansf = retry(times=5)(f)—retry(times=5)is called first, and whatever it returns is then called with the function.
So retry(times=5) must return a decorator. Which means three levels:
from functools import wraps
def repeat(times): # 1. takes the ARGUMENTS, returns a decorator
def decorator(func): # 2. takes the FUNCTION, returns a wrapper
@wraps(func)
def wrapper(*args, **kwargs): # 3. takes the CALL's arguments
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
print(" ping")
return "pong"
print(ping())
ping
ping
ping
pong
| Level | Name | Receives | Returns | Runs |
|---|---|---|---|---|
| 1 | repeat(times) |
the decorator’s arguments | the decorator | Once, at @ evaluation |
| 2 | decorator(func) |
the function being decorated | the wrapper | Once, at import |
| 3 | wrapper(*args, **kwargs) |
the call’s arguments | the function’s result | Every call |
Each level closes over the one above: wrapper reads times from repeat’s frame two levels up, and func from decorator’s frame one level up. Both live in cells — the closure machinery from earlier is doing all the work.
Forgetting the ()
This is the trap, and it is nasty because decoration succeeds silently:
@repeat # no () — so repeat receives the FUNCTION as `times`
def oops():
return 1
print(oops) # => <function repeat.<locals>.decorator at 0x103097380>
oops()
TypeError: repeat.<locals>.decorator() missing 1 required positional argument: 'func'
Trace it: @repeat means oops = repeat(oops), so times is bound to the function oops, and repeat dutifully returns decorator. No error. oops is now the decorator function. The explosion only happens later, at the call site, when decorator() is invoked with no arguments and complains about a missing func.
The tell is the error text itself: decorator() missing 1 required positional argument: 'func'. When a TypeError mentions func or names something ending in .decorator or .<locals>.wrapper, you forgot a pair of parentheses. Note that a decorator taking arguments always needs them, even when you want the defaults: @retry(), not @retry.
(There is a common trick to accept both — inspect whether the first argument is callable and branch — but it costs clarity and a reader has to unpick it. Prefer requiring the parentheses.)
Stacking: applied bottom-up, executed top-down
Stack two decorators and the order matters in two different directions at once. This is where people genuinely get lost, so let us prove both halves in one run:
def deco_a(func):
print(f" [A applied to {func.__name__}]")
@wraps(func)
def wrapper(*a, **k):
print(" A: before")
r = func(*a, **k)
print(" A: after")
return r
return wrapper
def deco_b(func):
print(f" [B applied to {func.__name__}]")
@wraps(func)
def wrapper(*a, **k):
print(" B: before")
r = func(*a, **k)
print(" B: after")
return r
return wrapper
@deco_a
@deco_b
def target():
print(" target body")
return "done"
print("--- now calling ---")
print(target())
[B applied to target]
[A applied to target]
--- now calling ---
A: before
B: before
target body
B: after
A: after
done
Read that output twice. At import, B was applied first — bottom-up. At call time, A ran first — top-down. Two opposite orders from one stack, and both are correct.
The substitution explains it in one line:
@deco_a
@deco_b
def target(): ...
# is EXACTLY:
target = deco_a(deco_b(target))
deco_b(target) must be evaluated before deco_a can be called with the result — that is just how nested calls work, and it is why the bottom decorator wraps the real function. And once built, deco_a’s wrapper is the outermost layer, so a call hits it first, and its post-code runs last as the stack unwinds.
| Order | Which decorator | Mnemonic | |
|---|---|---|---|
| Application (import time) | Bottom-up | The one nearest def wraps the real function |
Closest to the function gets there first |
| Execution: pre-code | Top-down | The topmost runs first | Outermost layer, hit first |
| Execution: post-code | Bottom-up | The topmost runs last | Unwinding reverses it |
| Exceptions | Propagate outward | The topmost sees it last | It can catch what inner ones re-raise |
This is not academic. It changes behaviour:
| Stack | Meaning | Result |
|---|---|---|
@timing over @retry |
timing(retry(f)) |
One timing line covering all retry attempts |
@retry over @timing |
retry(timing(f)) |
One timing line per attempt — three lines for three attempts |
@app.route over @login_required |
route(login_required(f)) |
✅ Auth runs on every request — correct |
@login_required over @app.route |
login_required(route(f)) |
❌ Route registered unauthenticated — the auth is bypassed |
@cache over @timing |
cache(timing(f)) |
Timing only on a cache miss (usually what you want) |
@timing over @cache |
timing(cache(f)) |
Times the cache lookup too (measures the cache, not the work) |
Look at rows 3 and 4. That is a real, shipped, exploitable authentication bypass, produced by swapping two adjacent lines, with no error at any point. Registration decorators (@app.route, @task, @register) belong on top — they must register the fully-wrapped function, not the naked one.
The rule that makes it stick: read a decorator stack top-down to know what happens, and bottom-up to know what wraps what. (And when a stack confuses you at runtime, walk f.__wrapped__ to count the layers and reach the real function — a debugging move that only works because every wrapper used @wraps. Step 3 of the lab does exactly that.)
Class-based decorators and decorating methods
__call__ makes a class a decorator
A decorator must be callable and return something callable. A function is the obvious choice; a class with __call__ is the other one, and it is better when the decorator has real state:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
wraps(func)(self) # yes — wraps works on instances too
def __call__(self, *args, **kwargs):
self.count += 1
print(f" call #{self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def hello():
return "hi"
hello(); hello()
print(hello.count) # => 2
print(hello.__name__) # => hello
print(type(hello)) # => <class '__main__.CountCalls'>
@CountCalls means hello = CountCalls(hello) — the class is called with the function, so __init__ receives it and hello is now an instance. Calling hello() invokes __call__. The state lives in an ordinary attribute you can read (hello.count), not a cell you must prise out with __closure__[0].cell_contents. Note wraps(func)(self): wraps(func) returns a decorator, and applying it to self copies the metadata onto the instance — easy to forget, since there is no @ to remind you.
| Function-based | Class-based | |
|---|---|---|
| State | In cells; needs nonlocal to mutate |
Plain self.x = ... attributes |
| Inspecting state | f.__closure__[0].cell_contents |
f.count |
| Metadata | @wraps(func) on the wrapper |
wraps(func)(self) in __init__ |
Extra methods (.reset(), .stats()) |
Awkward — attach to the wrapper | Natural |
| Decorating methods | Works out of the box | ⚠️ Breaks — needs __get__ (descriptor protocol) |
| Readability | The default; what everyone expects | Justify it with real state |
| Verdict | Default choice | When the decorator is an object with behaviour |
That “breaks on methods” row is why function-based decorators dominate. An instance stored as a class attribute is not a descriptor, so it never receives self — fixing it means implementing __get__, at which point the function version is simply less work.
Decorating methods: self just rides along
A method is a function, so a plain decorator works on one unchanged — self arrives as the first positional argument and passes straight through *args:
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f" {func.__name__} args={args}")
return func(*args, **kwargs)
return wrapper
class Service:
def __init__(self, name):
self.name = name
@trace
def run(self, n):
return f"{self.name}:{n}"
print(Service("svc").run(5))
run args=(<__main__.Service object at 0x102d805f0>, 5)
svc:5
args is (the instance, 5). The decorator never mentions self and never needs to — exactly why *args, **kwargs is the right wrapper signature. The one thing to know: the decorator applies to the underlying function at class-creation time, before self binding exists, so @trace sees a plain function and the instance only appears later, at call time, as args[0].
Two ordering rules for the built-in method decorators, which are descriptors and must be outermost:
class C:
@staticmethod # ✅ staticmethod outermost
@trace
def helper(x): ...
@property # ✅ property outermost
@trace
def value(self): ...
Put @trace above @staticmethod and it receives a staticmethod object rather than a function — on 3.10+ that is callable and mostly survives; on 3.9 it raises TypeError: 'staticmethod' object is not callable. Just keep them on top.
Real-world decorators
| Decorator | What it does | Where you have met it |
|---|---|---|
@timing |
Log wall-clock per call | Every profiling session ever |
@retry(times=3, delay=1) |
Re-run on transient failure | HTTP clients, flaky I/O, tenacity |
@lru_cache / @cache |
Memoise on the arguments | functools — measured below |
@log_calls |
Log arguments + return | Debugging, audit trails |
@app.route("/users") |
Register a URL → view | Flask, FastAPI |
@login_required / @requires("admin") |
Reject unauthenticated calls | Django, FastAPI dependencies |
@validate_args |
Type/range checks at the boundary | pydantic, @validate_call |
@pytest.fixture / @pytest.mark.parametrize |
Supply test dependencies / multiply a test | pytest |
@property / @staticmethod / @classmethod |
The descriptor trio | Every class you have written |
@dataclass |
Generate __init__/__repr__/__eq__ |
The OOP lessons |
@contextlib.contextmanager |
Turn a generator into a with block |
stdlib |
@functools.singledispatch |
Dispatch on the first argument’s type | stdlib |
@atexit.register |
Run at interpreter shutdown | stdlib |
@celery.task / @shared_task |
Register a background job | Celery |
The pattern across all of them: a cross-cutting concern that would otherwise be copy-pasted into every function. Timing, retrying, caching, auth, logging, registration — none of these are what your function is about, and all would be duplicated in twenty places without decorators. That is why the feature earns its complexity.
functools: the rest of the toolkit
lru_cache and cache
@lru_cache memoises: it stores results keyed by the arguments and returns the stored value when it sees them again. On a naively recursive function the effect is absurd:
from functools import lru_cache
def fib_slow(n):
return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)
@lru_cache(maxsize=None)
def fib_fast(n):
return n if n < 2 else fib_fast(n - 1) + fib_fast(n - 2)
Measured on CPython 3.12.3 (macOS, arm64), computing fib(32):
| Time | Function calls | |
|---|---|---|
fib_slow(32) |
~530 ms | 7,049,155 |
fib_fast(32) (@lru_cache) |
~0.014 ms | 33 |
| Ratio | ~37,000× faster | 213,610× fewer calls |
Seven million calls against thirty-three. The uncached version recomputes fib(10) hundreds of thousands of times; the cached one computes each fib(k) exactly once, turning O(2ⁿ) into O(n) with one line and no change to the logic. It is the highest-leverage decorator in the standard library.
The cache is introspectable, which is what makes it usable in production:
print(fib_fast.cache_info())
# => CacheInfo(hits=30, misses=33, maxsize=None, currsize=33)
fib_fast.cache_clear()
print(fib_fast.cache_info())
# => CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
print(fib_fast.cache_parameters())
# => {'maxsize': None, 'typed': False}
| API | Returns | Use for |
|---|---|---|
@lru_cache(maxsize=128) |
decorator | Bounded cache; LRU eviction past maxsize |
@lru_cache(maxsize=None) |
decorator | Unbounded — never evicts. ⚠️ grows forever |
@cache (3.9+) |
decorator | Exactly lru_cache(maxsize=None), no parentheses needed |
@lru_cache(typed=True) |
decorator | f(1) and f(1.0) cached separately |
f.cache_info() |
CacheInfo(hits, misses, maxsize, currsize) |
Measuring the hit rate |
f.cache_clear() |
None |
Tests, and releasing leaked references |
f.cache_parameters() |
dict |
Reading back the config (3.9+) |
f.__wrapped__ |
the original function | Calling it uncached |
@cached_property |
descriptor | Per-instance, computed once, no leak |
The caveats, which are sharp
1. Arguments must be hashable. The cache is a dict, so the key is a tuple of your arguments:
@cache
def total(items):
return sum(items)
print(total((1, 2, 3))) # => 6 tuple: fine
total([1, 2, 3]) # TypeError: unhashable type: 'list'
Lists, dicts and sets cannot be cache keys. Convert at the boundary — tuple(items), frozenset(tags).
2. It caches the object, not a copy. Mutate a cached return value and every future caller gets your mutation:
@cache
def get_list(n):
return [0] * n
a = get_list(3)
a.append(99)
print(get_list(3)) # => [0, 0, 0, 99] the cache handed back the SAME list
Return immutable values (tuples, frozen dataclasses) from cached functions, or copy on the way out.
3. On a method, it leaks every instance. This is the one that reaches production. @lru_cache on a method puts the cache on the class, and self is part of the key — so the cache holds a strong reference to every instance it has ever seen:
import gc, weakref
from functools import lru_cache
class Heavy:
def __init__(self, name):
self.name = name
self.blob = [0] * 100
@lru_cache(maxsize=None)
def compute(self, n):
return f"{self.name}:{n * n}"
def __repr__(self):
return f"Heavy({self.name!r})"
h = Heavy("h1")
h.compute(4)
ref = weakref.ref(h)
del h
gc.collect()
print(ref() is not None, "->", ref()) # => True -> Heavy('h1') STILL ALIVE
Heavy.compute.cache_clear()
gc.collect()
print(ref() is not None) # => False now it's freed
del h and a full gc.collect() did not free the object — a weakref proves it is still there, held by the class-level cache. In a long-running service creating objects per request, that is an unbounded memory leak that looks exactly like a slow crash at 3 a.m. The cache is also shared across all instances, so maxsize=128 is 128 entries total, not per object.
Use @cached_property for per-instance memoisation — it stores the value in the instance’s own __dict__, so it dies with the instance:
from functools import cached_property
class Good:
def __init__(self, name):
self.name = name
@cached_property
def expensive(self):
print(" computing once...")
return self.name.upper()
g = Good("abc")
print(g.expensive) # => computing once...
# => ABC
print(g.expensive) # => ABC no recomputation
The summary: cache pure functions with hashable arguments, immutable returns and a bounded key space — and reach for a real cache with TTL and invalidation the moment you need either.
partial
functools.partial freezes some arguments and hands back a new callable:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5), cube(2)) # => 25 8
print(int2 := partial(int, base=2)) # a partial of a builtin
print(int2("1011")) # => 11
print(list(map(partial(round, ndigits=2), [3.14159, 2.71828]))) # => [3.14, 2.72]
Positional arguments bind left to right, the main gotcha — partial(divide, 1) fixes the numerator:
def divide(a, b):
return a / b
half_of = partial(divide, 1)
print(half_of(4)) # => 0.25 that's 1/4, not 4/2
Use keywords when the position is ambiguous. The advantage over a lambda is introspection — a partial knows what it holds, and says so:
print(partial(power, exponent=2))
# => functools.partial(<function power at 0x103094ea0>, exponent=2)
print(lambda x: power(x, 2))
# => <function <lambda> at 0x1030f8720>
partial(f, x) |
lambda *a: f(x, *a) |
|
|---|---|---|
repr |
functools.partial(<function power...>, exponent=2) |
<function <lambda> at 0x...> |
| Introspectable | .func, .args, .keywords |
❌ Nothing |
| Picklable | ✅ (if f is) |
❌ PicklingError |
| Binds | At creation — no late binding | At call — ⚠️ late binding |
| Speed | Slightly faster (C implementation) | Slightly slower |
| Reads well for | Freezing arguments of an existing function | Small custom expressions |
That “binds at creation” row is why partial is a legitimate third fix for the late-binding trap.
singledispatch
@singledispatch turns a function into one that dispatches on the first argument’s type — a clean alternative to a tower of isinstance checks:
from functools import singledispatch
@singledispatch
def describe(value): # the fallback
return f"some {type(value).__name__}: {value!r}"
@describe.register
def _(value: int): # registered by the TYPE HINT
return f"int {value} (binary {value:b})"
@describe.register
def _(value: list):
return f"list of {len(value)} items"
@describe.register(str) # or by an explicit type
def _(value):
return f"string of length {len(value)}"
for v in [42, "hi", [1, 2, 3], 3.14, {"a": 1}]:
print(describe(v))
int 42 (binary 101010)
string of length 2
list of 3 items
some float: 3.14
some dict: {'a': 1}
It respects inheritance via the MRO, which produces one memorable gotcha — bool is a subclass of int:
print(describe(True)) # => int True (binary 1)
Use it for serialisers and formatters handling an open set of types. It dispatches on the first argument only (singledispatchmethod handles methods, skipping self), so it is not general multiple dispatch.
functools member |
What it does | Reach for it when |
|---|---|---|
wraps(func) |
Copy identity onto a wrapper | Every decorator you write |
lru_cache(maxsize=128) / cache |
Memoise on arguments | Pure, expensive, repeated calls |
cached_property |
Per-instance lazy attribute | Expensive value derived from self |
partial(f, *a, **kw) |
Freeze arguments → new callable | Pre-configuring an existing function |
reduce(f, it, init) |
Fold to one value | No builtin fits — and pass init |
singledispatch |
Type-based dispatch on arg 1 | Replacing an isinstance ladder |
total_ordering |
__lt__ + __eq__ → all four |
Comparable classes |
partialmethod |
partial for methods |
Pre-configured method variants |
cmp_to_key(f) |
Old-style comparator → key= |
Porting Python 2 sort code |
Where FP fits in Python, honestly
Python borrowed functional tools; it did not become a functional language. Two absences matter, and knowing them stops you writing Haskell in a language that will punish you for it.
There is no tail-call elimination. In a real functional language, a function whose last act is calling itself compiles into a loop and runs in constant stack space. Python does not do this, deliberately — Guido has argued the tracebacks are worth more. So recursion has a hard ceiling:
import sys
print(sys.getrecursionlimit()) # => 1000
def countdown(n):
if n == 0:
return "done"
return countdown(n - 1) # a TAIL call — and Python does not care
print(countdown(100)) # => done
print(countdown(10_000)) # RecursionError: maximum recursion depth exceeded
countdown(10_000) is trivially a loop, and Python still blows the stack. Raising sys.setrecursionlimit() is not a fix — it trades a clean RecursionError for a segfault when the real C stack runs out. In Python, deep iteration is a loop. Recursion is for tree-shaped problems (parsers, filesystem walks, divide-and-conquer) where the depth is logarithmic and the code is clearer for it.
Immutability is a convention, not a guarantee. There is no const, no final, no frozen-by-default:
CONFIG = {"retries": 3} # "constant" — by naming convention only
CONFIG["retries"] = 99 # nothing stops this
print(CONFIG) # => {'retries': 99}
Uppercase means “please don’t”, and that is all it means. Python gives you tuple, frozenset, @dataclass(frozen=True) and types.MappingProxyType — every one opt-in and shallow.
| Functional feature | Haskell / Clojure | Python | Consequence |
|---|---|---|---|
| Tail-call elimination | ✅ Guaranteed | ❌ Never | Deep recursion → RecursionError at ~1,000 |
| Immutable by default | ✅ | ❌ Opt-in, shallow | Defensive copying is on you |
| Persistent data structures | ✅ O(log n) updates | ❌ Copy is O(n) | acc + [x] folds are quadratic |
| Pure functions enforced | ✅ Type system | ❌ Nothing stops I/O anywhere | Purity is a code-review matter |
| Lazy evaluation | ✅ Default (Haskell) | Partial — iterators/generators | The one-shot trap |
| Currying | ✅ Automatic | Manual (partial) |
Fine — partial is good |
| Function composition | ✅ f . g |
❌ No operator | Nest calls or write a helper |
| Pattern matching | ✅ Core | Since 3.10 (match) |
Genuinely good; not FP-complete |
| First-class functions | ✅ | ✅ Yes | The one that carries this whole lesson |
| Closures | ✅ | ✅ Yes — with cells | Decorators, callbacks, factories |
The bottom two rows are why this lesson exists. Python’s functional inheritance is first-class functions and closures, and on those two foundations it built something genuinely excellent — the decorator. So take the parts that win: decorators for cross-cutting concerns, pure functions where you can, closures for small factories, comprehensions and generators for pipelines. Leave the rest: reduce where a builtin exists, deep recursion where a loop works, and point-free style and lambda chains anywhere at all.
The test is the same one that governs operator overloading: would a competent Python programmer, who has never seen this file, predict what it does? sum(map(int, lines)) passes. reduce(compose(f, g), xs, identity) does not.
Hands-on lab
Pure standard library — nothing to install, so no virtual environment is strictly required. (If you want the habit: python3 -m venv .venv && source .venv/bin/activate, or .venv\Scripts\activate on Windows, where the command is python, not python3.)
Check your version first — this lab targets Python 3.12+, and Step 6’s WRAPPER_ASSIGNMENTS output depends on it:
python3 --version
# Python 3.12.3
Create fp_lab.py and append each step as you go, running python3 fp_lab.py after each. Every output below is exact and reproducible except the wall-clock timings — the call counts in Step 4 are exact everywhere.
Step 1 — @timing: a decorator from first principles.
import functools
import inspect
import time
from functools import lru_cache, wraps
def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = (time.perf_counter() - start) * 1000
print(f" [timing] {func.__name__} took {elapsed:.1f} ms")
return wrapper
@timing
def sleep_a_bit(ms):
"""Burn some wall-clock so timing has something to report."""
time.sleep(ms / 1000)
return f"slept {ms}ms"
print(sleep_a_bit(50))
print("name preserved:", sleep_a_bit.__name__)
print("doc preserved :", sleep_a_bit.__doc__)
[timing] sleep_a_bit took 57.1 ms
slept 50ms
name preserved: sleep_a_bit
doc preserved : Burn some wall-clock so timing has something to report.
What just happened: the three-part pattern — take a function, define a wrapper closing over it, return the wrapper. The try/finally is the detail worth stealing: the timing line prints even if the function raises, while return func(...) inside the try still hands the result back. That is how you instrument a call without swallowing its result or hiding its exception.
Step 2 — @retry(times=3, delay=0): a decorator with arguments.
def retry(times=3, delay=0.0, exceptions=(Exception,)):
"""Retry func up to `times` attempts. Re-raises the last error."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last = None
for attempt in range(1, times + 1):
try:
result = func(*args, **kwargs)
if attempt > 1:
print(f" [retry] {func.__name__} succeeded on attempt {attempt}")
return result
except exceptions as exc:
last = exc
print(f" [retry] attempt {attempt}/{times} failed: "
f"{type(exc).__name__}: {exc}")
if attempt < times and delay:
time.sleep(delay)
raise last
return wrapper
return decorator
_calls = {"n": 0}
@retry(times=3, delay=0)
def flaky():
"""Fails twice, then succeeds. Deterministic on purpose."""
_calls["n"] += 1
if _calls["n"] < 3:
raise ConnectionError(f"boom on call {_calls['n']}")
return f"ok on call {_calls['n']}"
print(flaky())
print()
print("--- and when every attempt fails, the last exception is re-raised ---")
@retry(times=2, delay=0)
def always_bad():
raise TimeoutError("upstream down")
try:
always_bad()
except TimeoutError as e:
print(f" caught by caller -> TimeoutError: {e}")
[retry] attempt 1/3 failed: ConnectionError: boom on call 1
[retry] attempt 2/3 failed: ConnectionError: boom on call 2
[retry] flaky succeeded on attempt 3
ok on call 3
--- and when every attempt fails, the last exception is re-raised ---
[retry] attempt 1/2 failed: TimeoutError: upstream down
[retry] attempt 2/2 failed: TimeoutError: upstream down
caught by caller -> TimeoutError: upstream down
What just happened: three levels — retry(times, delay) → decorator(func) → wrapper(*args) — and wrapper reads times from two frames up through a cell. The failure path matters as much as the success path: after the last attempt it re-raises the last exception rather than returning None, so a caller can still handle a genuine outage. A retry decorator that silently returns None on total failure is worse than no retry at all. And exceptions=(Exception,) is a parameter for a reason — in production, narrow it to the transient errors, so a ValueError in your own code fails fast instead of being retried three times.
Step 3 — stacking: prove bottom-up application, top-down execution.
_calls2 = {"n": 0}
@timing
@retry(times=3, delay=0)
def flaky_timed():
_calls2["n"] += 1
time.sleep(0.01)
if _calls2["n"] < 3:
raise ConnectionError(f"boom on call {_calls2['n']}")
return f"ok on call {_calls2['n']}"
print(flaky_timed())
print(" ^ ONE timing line covering ALL 3 attempts -> timing is OUTERMOST")
print()
_calls3 = {"n": 0}
@retry(times=3, delay=0)
@timing
def timed_flaky():
_calls3["n"] += 1
time.sleep(0.01)
if _calls3["n"] < 3:
raise ConnectionError(f"boom on call {_calls3['n']}")
return f"ok on call {_calls3['n']}"
print(timed_flaky())
print(" ^ THREE timing lines, one per attempt -> timing is INNERMOST")
print()
print("--- unwrap the chain to see the real order ---")
f = flaky_timed
depth = 0
while hasattr(f, "__wrapped__"):
depth += 1
f = f.__wrapped__
print(f" layers of wrapping : {depth}")
print(" innermost function :", f.__qualname__)
[retry] attempt 1/3 failed: ConnectionError: boom on call 1
[retry] attempt 2/3 failed: ConnectionError: boom on call 2
[retry] flaky_timed succeeded on attempt 3
[timing] flaky_timed took 41.9 ms
ok on call 3
^ ONE timing line covering ALL 3 attempts -> timing is OUTERMOST
[timing] timed_flaky took 15.0 ms
[retry] attempt 1/3 failed: ConnectionError: boom on call 1
[timing] timed_flaky took 15.0 ms
[retry] attempt 2/3 failed: ConnectionError: boom on call 2
[timing] timed_flaky took 15.0 ms
[retry] timed_flaky succeeded on attempt 3
ok on call 3
^ THREE timing lines, one per attempt -> timing is INNERMOST
--- unwrap the chain to see the real order ---
layers of wrapping : 2
innermost function : flaky_timed
What just happened: the same two decorators, swapped, mean different things. @timing on top is timing(retry(f)) — one line at ~42 ms covering all three attempts (“how long did this take the caller?”). @retry on top is retry(timing(f)) — three lines at ~15 ms each (“how long does one attempt take?”). Both are legitimate; only one answers your question. And the __wrapped__ walk proves two layers of wrapping are really there, reaching the original function — a link that exists only because both wrappers used @wraps.
Step 4 — @lru_cache on a slow recursive function: measure it.
N = 32
counts = {"slow": 0, "fast": 0}
def fib_slow(n):
counts["slow"] += 1
return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)
@lru_cache(maxsize=None)
def fib_fast(n):
counts["fast"] += 1
return n if n < 2 else fib_fast(n - 1) + fib_fast(n - 2)
t0 = time.perf_counter()
r_slow = fib_slow(N)
slow_s = time.perf_counter() - t0
t0 = time.perf_counter()
r_fast = fib_fast(N)
fast_s = time.perf_counter() - t0
print(f"fib({N}) = {r_slow} (both agree: {r_slow == r_fast})")
print(f"uncached : {slow_s * 1000:9.2f} ms {counts['slow']:>9,} calls")
print(f"lru_cache : {fast_s * 1000:9.4f} ms {counts['fast']:>9,} calls")
print(f"speedup : {slow_s / fast_s:,.0f}x")
print(f"cache_info : {fib_fast.cache_info()}")
t0 = time.perf_counter()
fib_fast(N)
hit_s = time.perf_counter() - t0
print(f"2nd call (pure hit) : {hit_s * 1e6:.2f} us -> {fib_fast.cache_info()}")
fib_fast.cache_clear()
print(f"after cache_clear() : {fib_fast.cache_info()}")
print()
print("--- the caveat: arguments must be HASHABLE ---")
@functools.cache
def total(items):
return sum(items)
print(" total((1, 2, 3)) ->", total((1, 2, 3)))
try:
total([1, 2, 3])
except TypeError as e:
print(f" total([1, 2, 3]) -> TypeError: {e}")
fib(32) = 2178309 (both agree: True)
uncached : 539.95 ms 7,049,155 calls
lru_cache : 0.0141 ms 33 calls
speedup : 38,227x
cache_info : CacheInfo(hits=30, misses=33, maxsize=None, currsize=33)
2nd call (pure hit) : 0.21 us -> CacheInfo(hits=31, misses=33, maxsize=None, currsize=33)
after cache_clear() : CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
--- the caveat: arguments must be HASHABLE ---
total((1, 2, 3)) -> 6
total([1, 2, 3]) -> TypeError: unhashable type: 'list'
⚠️ Your milliseconds will differ; the call counts will not. 7,049,155 versus 33 is the number that matters — and it is exact on every machine and every version.
What just happened: one line of decorator turned exponential into linear. misses=33 means each of fib(0)…fib(32) was computed exactly once; hits=30 means every other request came from the dict. The uncached version made 213,610× more calls for the identical answer. cache_clear() then reset it — essential in tests, where a cache surviving between cases is a genuine source of “passes alone, fails in the suite.” And the TypeError is the price of admission: cache keys are dict keys, so every argument must be hashable.
Step 5 — the late-binding closure bug, reproduced and fixed.
def build_broken():
handlers = []
for code in (200, 404, 500):
handlers.append(lambda: f"handling {code}")
return handlers
broken = build_broken()
print("BROKEN :", [h() for h in broken])
print(" all three share ONE cell:", broken[0].__closure__[0] is broken[1].__closure__[0])
print(" cell now holds :", broken[0].__closure__[0].cell_contents)
def build_fixed_default():
handlers = []
for code in (200, 404, 500):
handlers.append(lambda code=code: f"handling {code}")
return handlers
fixed = build_fixed_default()
print("FIXED :", [h() for h in fixed])
print(" __closure__ is now :", fixed[0].__closure__)
print(" snapshot in __defaults__:", [h.__defaults__ for h in fixed])
def build_fixed_factory():
def make(code):
return lambda: f"handling {code}"
return [make(c) for c in (200, 404, 500)]
fac = build_fixed_factory()
print("FACTORY:", [h() for h in fac])
print(" each has its OWN cell :", [h.__closure__[0].cell_contents for h in fac])
BROKEN : ['handling 500', 'handling 500', 'handling 500']
all three share ONE cell: True
cell now holds : 500
FIXED : ['handling 200', 'handling 404', 'handling 500']
__closure__ is now : None
snapshot in __defaults__: [(200,), (404,), (500,)]
FACTORY: ['handling 200', 'handling 404', 'handling 500']
each has its OWN cell : [200, 404, 500]
What just happened: you did not just see the bug — you saw its cause, printed. broken[0].__closure__[0] is broken[1].__closure__[0] is True: one cell, three lambdas, holding 500 because that is where the loop left it. The default-argument fix captures nothing (__closure__ is None), snapshotting into __defaults__ at def time. The factory fix gives each lambda its own cell, because each make() call created a new frame. Three mechanisms, visible in three attributes.
Step 6 — the missing-@wraps introspection loss.
def bare_deco(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def good_deco(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bare_deco
def charge_bare(amount: int, currency: str = "INR") -> str:
"""Charge the customer."""
return f"{currency} {amount}"
@good_deco
def charge_good(amount: int, currency: str = "INR") -> str:
"""Charge the customer."""
return f"{currency} {amount}"
rows = [
("__name__", charge_bare.__name__, charge_good.__name__),
("__doc__", charge_bare.__doc__, charge_good.__doc__),
("__qualname__", charge_bare.__qualname__, charge_good.__qualname__),
("signature", str(inspect.signature(charge_bare)), str(inspect.signature(charge_good))),
("__annotations__", str(charge_bare.__annotations__), str(charge_good.__annotations__)),
]
print(f" {'attribute':<16} {'WITHOUT @wraps':<26} WITH @wraps")
print(f" {'-' * 16} {'-' * 26} {'-' * 30}")
for name, bad, good in rows:
print(f" {name:<16} {str(bad):<26} {good}")
print()
print("--- why it actually bites: a name-keyed registry collapses ---")
registry = {}
def register(f):
registry[f.__name__] = f
return f
@register
@bare_deco
def handler_a():
return "a"
@register
@bare_deco
def handler_b():
return "b"
print(" registry keys:", list(registry.keys()), "<- TWO functions, ONE key")
print(" registry['wrapper']() ->", registry["wrapper"]())
print()
print(" @wraps copies:", functools.WRAPPER_ASSIGNMENTS)
print(" and sets __wrapped__ ->", charge_good.__wrapped__.__qualname__)
attribute WITHOUT @wraps WITH @wraps
---------------- -------------------------- ------------------------------
__name__ wrapper charge_good
__doc__ None Charge the customer.
__qualname__ bare_deco.<locals>.wrapper charge_good
signature (*args, **kwargs) (amount: int, currency: str = 'INR') -> str
__annotations__ {} {'amount': <class 'int'>, 'currency': <class 'str'>, 'return': <class 'str'>}
--- why it actually bites: a name-keyed registry collapses ---
registry keys: ['wrapper'] <- TWO functions, ONE key
registry['wrapper']() -> b
@wraps copies: ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__', '__type_params__')
and sets __wrapped__ -> charge_good
What just happened: the left column is what your API docs, your CLI framework and your debugger see without one line of @wraps. But the registry is the part to remember: two functions registered, one key, and handler_a unreachable forever. registry["wrapper"]() returns "b" — the second decoration overwrote the first, because both wrappers are named wrapper. That is a route that 404s, or a task that never runs, with no error anywhere.
Version note:
__type_params__in that tuple is 3.12+ (PEP 695). On 3.9 you will see five entries, without it.
You have now built both decorators, proved the stacking order two ways, measured a 38,000× cache speedup, and dissected Python’s two most famous silent failures down to the cell object and the dict key.
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
f.__name__ is 'wrapper'; docs/help() show wrapper(*args, **kwargs) |
No @wraps(func) on the wrapper |
Add @wraps(func) above def wrapper — always |
| A name-keyed registry silently keeps one of several functions | Every wrapper is named wrapper, so keys collide |
@wraps(func) — the keys become the real names |
TypeError: repeat.<locals>.decorator() missing 1 required positional argument: 'func' |
Used @repeat when it takes arguments — the function was bound to the first parameter |
Add the parentheses: @repeat() or @repeat(times=3) |
Decorated function returns None for everything |
The wrapper calls func(...) but never returns it |
return func(*args, **kwargs) |
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value |
Assigned to an enclosing variable inside a closure — assignment made it local | nonlocal count (or global for module scope). 3.9 wording: local variable 'count' referenced before assignment |
SyntaxError: no binding for nonlocal 'x' found |
nonlocal x with no x in any enclosing function |
Create x in the enclosing function, or use global |
SyntaxError: nonlocal declaration not allowed at module level |
nonlocal at module scope |
Use global, or nest the function |
| Lambdas/handlers built in a loop all return the last value; no error | Late binding — the closure captures the variable, read at call time | lambda i=i: ..., partial(f, i), or a factory function |
TypeError: unhashable type: 'list' from an @lru_cached function |
Cache keys are dict keys; lists/dicts/sets can’t be hashed | Convert at the boundary: tuple(items), frozenset(tags) |
| Memory grows forever; objects never freed | @lru_cache on a method — the class-level cache holds self |
Use @cached_property, or cache_clear(), or cache a module-level function |
| Cached function returns data someone mutated | The cache stores the object, not a copy | Return immutables (tuple, frozen dataclass), or copy on return |
| Test passes alone, fails in the suite | An @lru_cache survived between tests |
f.cache_clear() in a fixture/setUp |
list(m) is ['1','2'] then [] on the second call |
map/filter are one-shot iterators — exhausted |
Materialise once: results = list(map(...)), then reuse |
if filter(...) is always true, even with no matches |
An iterator has no __len__/__bool__ → always truthy |
results = list(filter(...)) then if results: |
TypeError: object of type 'map' has no len() |
map is an iterator, not a list |
len(list(m)) — or don’t; count with sum(1 for _ in m) |
TypeError: reduce() of empty iterable with no initial value |
reduce has nothing to seed the accumulator |
Pass initial: reduce(f, xs, 0) |
map(f, xs, ys) silently drops items |
Multiple iterables stop at the shortest | Check lengths, or zip(xs, ys, strict=True) first |
| Auth decorator never runs; endpoint is public | @login_required above @app.route → the naked function was registered |
Registration decorators go on top |
RecursionError: maximum recursion depth exceeded on a tail-recursive function |
Python has no tail-call elimination | Rewrite as a loop. ⚠️ Don’t raise setrecursionlimit — you trade the error for a segfault |
TypeError: 'staticmethod' object is not callable (3.9) |
A decorator was placed above @staticmethod |
@staticmethod outermost, your decorator below it |
A class-based decorator on a method never gets self |
An instance attribute isn’t a descriptor — no __get__ |
Use a function-based decorator, or implement __get__ |
ValueError: Cell is empty from cell_contents |
Read a cell before the enclosing scope assigned it | Assign before the inner function runs |
Three of these will cost you the most hours.
1. The missing @wraps — an invisible, delayed edit. Nothing breaks today. Then someone generates the API docs and every entry is called wrapper; or FastAPI cannot build a request model because inspect.signature says (*args, **kwargs); or a plugin registry keyed on __name__ quietly keeps one handler out of twelve, and eleven routes 404 with no error in any log. The diff that caused it was three weeks ago and mentions none of this. The check is one line — print(my_func.__name__), and if it says wrapper, that is your bug. The permanent fix is a habit: the line after def decorator(func): is @wraps(func), every time.
2. Late binding — the one that returns wrong data instead of raising. Every other mistake here produces a traceback. This one produces ['handling 500', 'handling 500', 'handling 500'] and lets it flow into your database. It persists because the code looks obviously correct — the loop plainly has three different values. But it created one variable, rebound three times, and every closure captured the same cell: broken[0].__closure__[0] is broken[1].__closure__[0] is True, and that is the whole bug in one expression. The trigger to memorise: the instant a lambda or def inside a for loop references the loop variable, bind it now — lambda code=code:.
3. @lru_cache on a method — the leak that looks like a slow crash. One of the most natural-looking things in Python and one of the worst. The cache is created once, on the class, and self becomes part of every key — so it holds a strong reference to every instance it has ever seen, forever. A weakref proves it: del h plus gc.collect(), and the object is still alive. In a service creating an object per request, memory climbs all day and the process is OOM-killed at 3 a.m. with no traceback. It is also shared across all instances, so maxsize=128 is 128 entries total and instance A evicts instance B. Use @cached_property per instance, or move the function to module level where self never enters the key.
Cheat-sheet
| Syntax | What it does |
|---|---|
map(f, xs) |
Lazy iterator of f(x). ⚠️ Not a list; one-shot; always truthy |
map(f, xs, ys) |
f(x, y) pairwise; ⚠️ stops at the shortest |
filter(p, xs) |
Lazy iterator of items where p(x) is truthy |
filter(None, xs) |
Drops every falsy item — no lambda needed |
list(map(...)) |
Materialise. Needed for len(), indexing, a second pass |
itertools.filterfalse(p, xs) |
The inverse of filter |
itertools.starmap(f, tuples) |
f(*args) over an iterable of argument tuples |
functools.reduce(f, xs, init) |
Fold to one value. Always pass init |
reduce on empty, no init |
TypeError: reduce() of empty iterable with no initial value |
sum / math.prod / any / all / max |
Prefer these — faster, clearer, short-circuit |
itertools.accumulate(xs) |
reduce that yields every intermediate, lazily |
[f(x) for x in xs] |
The default. Beats map on 3.12 (PEP 709 inlining) |
def outer(): def inner(): ... ; return inner |
A closure — inner remembers outer’s variables |
f.__closure__ |
Tuple of cell objects — None if it captured nothing |
f.__closure__[0].cell_contents |
The captured value itself |
f.__code__.co_freevars |
The captured names, in __closure__ order |
nonlocal x |
Rebind x in the nearest enclosing function. Needed to write |
global x |
Rebind x at module level. Skips enclosing functions |
| Reading an enclosing var | Needs no declaration — only writing does |
lambda i=i: i |
The late-binding fix. Snapshots at def time into __defaults__ |
def deco(f): def w(*a, **k): return f(*a, **k); return w |
The decorator skeleton |
@deco above def f |
Exactly f = deco(f) — an assignment, run at import |
@wraps(func) |
Mandatory. Copies __name__/__doc__/__annotations__; sets __wrapped__ |
functools.WRAPPER_ASSIGNMENTS |
What @wraps copies (3.12 adds __type_params__) |
f.__wrapped__ |
The original function — walk it to unwrap a stack |
@deco(arg) |
Needs three levels: deco(arg) → decorator(func) → wrapper(*a) |
@deco when it takes args |
TypeError: ... missing 1 required positional argument: 'func' at call time |
@a over @b |
a(b(f)) — applied bottom-up, executed top-down |
@app.route / @register |
Put registration decorators on top — else the naked function registers |
class D: def __call__(self, ...) |
Class-based decorator; wraps(func)(self) in __init__ |
@lru_cache(maxsize=None) / @cache |
Memoise. fib(32): ~530 ms → ~0.014 ms |
f.cache_info() |
CacheInfo(hits, misses, maxsize, currsize) |
f.cache_clear() |
Empty it — essential in tests, and frees leaked instances |
@lru_cache on a method |
⚠️ Leaks every instance — use @cached_property |
@cached_property |
Per-instance, computed once, stored in the instance __dict__ |
partial(f, x) |
Freeze arguments → new callable. Binds left to right |
p.func / p.args / p.keywords |
A partial is introspectable; a lambda is not |
@singledispatch + @f.register |
Dispatch on the first argument’s type. ⚠️ bool is an int |
sys.setrecursionlimit(n) |
⚠️ Not a fix for RecursionError — trades it for a segfault. Use a loop |
python3 -m venv .venv && source .venv/bin/activate |
Only needed for third-party libs — none here |
Interview and exam questions
Q: What does @decorator actually do, and when does it run?
A: @deco above def f means exactly f = deco(f) — an assignment, nothing more. Two consequences people miss: it runs at definition time (put a print() in the decorator body and it fires at import, before anything is called), and afterwards the name refers to a different object — the original survives only inside the wrapper’s closure cell, reachable via __wrapped__ if you used @wraps.
Q: Why was reduce moved out of the builtins, and what should you use instead?
A: Readability — Guido’s argument was that outside + and *, nobody can read a reduce at a glance without mentally simulating the fold. There is a specialised builtin for almost every fold you want: sum, math.prod, any, all, max/min. They are also faster — measured on 3.12, sum(data) is 2.5 µs versus 17.5 µs for reduce(operator.add, data) (7×) and 33.9 µs with a lambda (13.6×) — and any/all short-circuit where reduce always visits every element. It still earns its import for custom associative folds with no builtin, like merging dicts with operator.or_. Two traps: TypeError: reduce() of empty iterable with no initial value unless you pass initial, and reduce(lambda acc, x: acc + [x], xs, []) is accidentally O(n²) because it copies the list every step. (Relatedly, map is not faster than a comprehension on 3.12 — PEP 709 flipped that; see the measured table above.)
Q: What is a closure, and how would you prove one exists?
A: A function bundled with the enclosing-scope variables it references, which survive after the enclosing function has returned. Prove it by printing the machinery: double = multiplier(2) gives double.__closure__ → (<cell at 0x...: int object at 0x...>,), double.__closure__[0].cell_contents → 2, and double.__code__.co_freevars → ('factor',). A function that captures nothing has __closure__ is None — the definitive test. The mechanism: when the compiler sees an inner function reference an outer local, it stores that variable in a heap-allocated cell rather than the frame, so it outlives the call. Critically, a cell is a live reference, not a snapshot, and sibling closures share it — the entire explanation of late binding.
Q: nonlocal versus global — and why does count += 1 fail in a closure?
A: It fails with UnboundLocalError: cannot access local variable 'count' where it is not associated with a value (3.11+ wording; 3.9 says local variable 'count' referenced before assignment). The rule: any assignment in a function body makes that name local to the entire function, decided at compile time. count += 1 is an assignment, so count is local to the inner function, and reading it on the right-hand side finds it unassigned. Note the asymmetry — pure reads of an enclosing variable need no declaration; only writing does. nonlocal count binds to the nearest enclosing function’s variable and fails at compile time with SyntaxError: no binding for nonlocal 'x' found if there is none. global count reaches all the way out to module scope, skipping every enclosing function. They are not interchangeable: nonlocal will never touch a module-level variable, and at module level it is a SyntaxError.
Q (coding): funcs = [lambda: i for i in range(3)] — what does [f() for f in funcs] return and why?
A: [2, 2, 2], not [0, 1, 2]. This is late binding: the closures capture the variable i, not its value, and look it up when called — by which time the loop has finished and left 2 behind. All three share one cell: funcs[0].__closure__[0] is funcs[1].__closure__[0] is True, and cell_contents is 2. Fix by snapshotting at definition time with [lambda i=i: i for i in range(3)] (default args evaluate once, at def time — the fixed lambdas then have __closure__ is None and the value in __defaults__), or a factory def make(i): return lambda: i (new call → new frame → three separate cells), or partial(identity, i). The danger is that it raises no exception. And it is not strictly a closure bug: the same loop at module level has no cell at all (__closure__ is None, because i is a global) and still gives [2, 2, 2] — the real issue is name lookup at call time.
Q: Why is functools.wraps mandatory, not cosmetic?
A: Without it the name is rebound to wrapper, so f.__name__ is 'wrapper', f.__doc__ is None, inspect.signature(f) collapses to (*args, **kwargs), and f.__annotations__ is {}. That breaks help(), generated docs, pytest collection, debuggers, tracebacks, and every framework that introspects signatures to do its job — FastAPI, Click, Typer, pydantic. The concrete killer: any registry keyed on __name__ collapses, because every wrapper is called wrapper — register two decorated handlers and the dict has one key, with the first silently unreachable. @wraps(func) copies functools.WRAPPER_ASSIGNMENTS (__module__, __name__, __qualname__, __doc__, __annotations__, plus __type_params__ on 3.12+), updates __dict__, and sets __wrapped__ so inspect.signature can recover the real signature.
Q: Why does a decorator with arguments need three levels?
A: Substitution. @deco is f = deco(f), so deco receives the function. But @deco(x) is f = deco(x)(f) — deco(x) is called first, and its return value is then called with the function. So deco(x) must return a decorator: level 1 takes the decorator’s arguments and returns level 2, which takes the function and returns level 3, the wrapper, which takes the call’s arguments. Each level closes over the one above via cells. The classic bug is omitting the parentheses: @deco when deco takes arguments binds the function to the first parameter, returns the inner decorator, and succeeds silently — the explosion comes later at the call site with TypeError: deco.<locals>.decorator() missing 1 required positional argument: 'func'. A decorator that takes arguments always needs (), even for defaults.
Q: In @a over @b over def f, what order do things happen?
A: Two opposite orders at once, which is why it confuses people. It desugars to f = a(b(f)), so application is bottom-up: b(f) is evaluated before a can be called with the result, so the decorator nearest the def wraps the real function and a’s wrapper ends up outermost. But execution is top-down: a call hits the outermost wrapper first, so a’s pre-code runs first and its post-code runs last, as the stack unwinds. Concretely, @timing over @retry gives one timing line covering all three attempts; reversed, three. It also matters for correctness: @login_required placed above @app.route registers the naked function, so the auth check never runs — an authentication bypass from swapping two adjacent lines, with no error at any point. Registration decorators go on top. Mnemonic: read top-down for what happens, bottom-up for what wraps what.
Q: How much does @lru_cache actually help, and when does it hurt?
A: On naive recursive fib(32), measured on 3.12: ~530 ms and 7,049,155 calls uncached versus ~0.014 ms and 33 calls cached — roughly 37,000× faster, because memoisation turns O(2ⁿ) into O(n) in one line. It hurts in four ways. Arguments must be hashable (total([1,2,3]) → TypeError: unhashable type: 'list'). It caches the object, not a copy, so a caller mutating a returned list poisons every future call. maxsize=None on unbounded input is an unbounded memory leak. And worst, on a method it leaks every instance — the cache lives on the class with self in the key, so a weakref shows the object alive after del and a full gc.collect(). Use @cached_property per instance, and cache_clear() in test fixtures, or a cache surviving between tests gives you “passes alone, fails in the suite.”
Q (coding): Write a @retry decorator that takes a number of attempts and preserves the function’s identity.
A:
from functools import wraps
def retry(times=3, delay=0.0, exceptions=(Exception,)):
def decorator(func):
@wraps(func) # 1. mandatory
def wrapper(*args, **kwargs): # 2. signature-agnostic
last = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs) # 3. RETURN the value
except exceptions as exc:
last = exc
if attempt < times and delay:
time.sleep(delay)
raise last # 4. re-raise, don't return None
return wrapper
return decorator
The four things an interviewer is checking: @wraps(func) is present; the wrapper is (*args, **kwargs) so it wraps anything; the success path returns the value; and total failure re-raises the last exception instead of falling off the end and returning None. Bonus marks for exceptions being a parameter — retrying a ValueError in your own code three times is just being slow before failing — and for noting that real implementations add exponential backoff and jitter (tenacity does this properly).
Key takeaways
@decois exactlyf = deco(f), and it runs at import. A decorator is an ordinary function taking a function and returning another — no magic. The body executes at definition time (which is how@app.routeregisters a view nobody has called), and afterwards the name points at the wrapper, your original surviving only in a closure cell.functools.wrapsis mandatory, and the reason is not cosmetic. Without it__name__becomes'wrapper',__doc__becomesNone, andinspect.signaturecollapses to(*args, **kwargs)— breakinghelp(), docs, pytest, debuggers, and every framework that introspects signatures. The concrete failure: a registry keyed on__name__silently keeps one of two decorated handlers. The line afterdef decorator(func):is always@wraps(func).- A closure stores variables in cell objects you can print, and they are live, not snapshots.
f.__closure__[0].cell_contentsshows the captured value;__closure__ is Nonemeans nothing was captured. Because the cell is shared and read at call time, a loop that builds functions gives[2, 2, 2]— with no exception. Bind at definition time withlambda i=i:, or use a factory for a separate cell each. - Reads are free; writes need
nonlocal. Any assignment makes a name local to the whole function at compile time, socount += 1in a closure raisesUnboundLocalError: cannot access local variable 'count'...(3.11+ wording).nonlocalreaches one function layer out;globalreaches to the module. A closure is a class with exactly one method — want a second, or arepr, or a reset? Write the class. - Stacks apply bottom-up and execute top-down.
@aover@bisa(b(f)), sobwraps the real function butaruns first and finishes last.@timingover@retrytimes all three attempts; reversed, it times each. And@login_requiredunder@app.routeis an auth bypass with no error — registration decorators go on top. @lru_cacheis the best line-per-value in the stdlib, with three sharp edges.fib(32): ~530 ms and 7,049,155 calls → ~0.014 ms and 33 calls. But arguments must be hashable (TypeError: unhashable type: 'list'), it hands back the cached object rather than a copy, and on a method it leaks every instance forever — the cache is on the class andselfis in the key. Use@cached_propertyper instance andcache_clear()in tests.- The functional trio is narrower than the folklore.
map/filterare lazy one-shot iterators — always truthy, nolen, empty on a second pass.reducewas exiled for readability and raises on an empty sequence withoutinitial;sumbeats it 7× (2.5 vs 17.5 µs), and 13.6× with alambda. And on 3.12 comprehensions now beatmap(42.4 vs 60.4 µs) thanks to PEP 709 inlining — the opposite of 3.9. Distrust any performance claim that does not name a version. - Python is not a functional language, and writing it as one is a mistake. No tail-call elimination (tail-recursive
countdown(10_000)is aRecursionError; raising the limit buys a segfault), and immutability is a naming convention. What Python took from FP — first-class functions and closures — it used to build the decorator. Take that, and leave point-free style at the door.
The pieces now connect. First-class functions said a function is a value; this lesson showed you the cell where that value keeps its memory, and turned it into @retry(times=3). Every @ you meet from here — in pytest, FastAPI, Django, Celery — is that same three-line skeleton with a better name. And when one of them misbehaves, you now know to reach for __wrapped__, __closure__[0].cell_contents, and cache_info() rather than guessing.