Python Lesson 21 of 71

Functional Python: map/filter/reduce, Closures & Decorators

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.

Python decorator wrapping: at definition time the @ lines execute as f = timing(retry(f)), applying bottom-up so retry wraps the real function first and timing wraps that wrapper, rebinding the name f to a wrapper closure that holds the original function in a cell and exposes it via the @wraps-set wrapped attribute; a later call then executes top-down through timing's pre-code and retry's pre-code into the original body, which returns 5 or raises so retry loops and retries, before unwinding in reverse through retry's post-code and timing's post-code and finally returning the value 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:

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 nowlambda 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 closureinner 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_contents2, 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


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.

pythonfunctional-programmingdecoratorsclosuresmapfilterreducefunctoolslru-cachememoisationnonlocalhigher-order-functionswrapspartial
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments