What makes something a closure
def make_multiplier(factor):
def multiply(x):
return x * factor # `factor` is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
double(5) # 10
triple(5) # 15
multiply is a closure over factor — even after make_multiplier
returns, multiply still has access to its own factor, because Python
keeps the enclosing variable alive as long as any closure references it.
double and triple each capture a different factor because each
call to make_multiplier creates a fresh scope.
The late-binding trap
funcs = []
for i in range(3):
funcs.append(lambda: i)
[f() for f in funcs] # [2, 2, 2] -- NOT [0, 1, 2]!
Every lambda captures the variable i by reference, not its value at
the time the lambda was created. Since all three lambdas share the same
enclosing scope (the loop body doesn't create a new scope per iteration —
loops don't introduce scopes in Python at all), by the time they're
called, i has already finished the loop and holds its final value, 2.
The fix: bind the value via a default argument
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # default arg is evaluated NOW, at def-time
[f() for f in funcs] # [0, 1, 2]
Default argument values are evaluated once, when the lambda/def is
created — using i=i copies the current value of i into the
function's own default, decoupling it from the loop variable's later
mutations.
Alternative fix: a factory function
def make_getter(value):
return lambda: value # closes over `value`, a fresh parameter per call
funcs = [make_getter(i) for i in range(3)]
[f() for f in funcs] # [0, 1, 2]
Each call to make_getter creates a genuinely new scope with its own
value, so each returned lambda captures a distinct variable — this is
the same principle as make_multiplier above, just applied to fix the
loop pitfall.
Interview-ready summary: Closures capture variables by reference to the enclosing scope, not by value — which is powerful for building factories/decorators but means closures created in a loop all share the loop variable and see its final value unless you force early binding (default argument trick or a separate factory function per iteration).
Related Resources
What @decorator actually does
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello, {name}"
# equivalent to:
# def greet(name): ...
# greet = shout(greet)
greet("ada") # 'HELLO, ADA'
@shout just rebinds the name greet to whatever shout(greet) returns
— here, the wrapper closure, which calls the original greet and
transforms its result. func is captured in wrapper's closure, so it's
still callable even though greet itself has been reassigned.
A decorator that takes its own arguments
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(times=3)
def roll_die():
import random
return random.randint(1, 6)
# equivalent to:
# roll_die = repeat(times=3)(roll_die)
roll_die() # [4, 1, 6] (three rolls)
Three levels: repeat(times) returns decorator; decorator(func)
returns wrapper; calling wrapper(...) finally runs the original
function. @repeat(times=3) evaluates repeat(times=3) first (producing
decorator), then applies that to roll_die — this is why a
parameterized decorator always needs parentheses even with no arguments
inside them (@repeat()), while a plain decorator never does (@shout,
not @shout()).
Stacking multiple decorators
@decorator_a
@decorator_b
def f(): ...
# equivalent to: f = decorator_a(decorator_b(f))
Decorators apply bottom-up: the one closest to the function runs first (wraps first), so the outermost decorator in the source is the outermost wrapper at call time.
Class-based decorators
class CountCalls:
def __init__(self, func):
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
@CountCalls
def greet():
return "hi"
greet(); greet()
greet.calls # 2
Any object with __call__ can be a decorator — a class-based decorator
is useful when the wrapper needs to hold non-trivial state (here, a call
counter) rather than a single closed-over variable.
Interview-ready summary: @decorator is sugar for func = decorator(func); a decorator that itself takes arguments needs an extra
function layer (args -> decorator -> wrapper). Decorators stack bottom-up,
and anything callable — including a class implementing __call__ — can
serve as one.
Related Resources
The problem without wraps
def logged(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"""Add two numbers."""
return a + b
add.__name__ # 'wrapper' -- lost the real name!
add.__doc__ # None -- lost the docstring!
help(add) # shows wrapper's (empty) signature/doc, not add's
Every function decorated with logged reports its name as "wrapper" and
loses its docstring — this breaks help(), auto-generated API docs,
debuggers, and any code that inspects func.__name__ (e.g., logging
frameworks that print the function name).
The fix
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"""Add two numbers."""
return a + b
add.__name__ # 'add'
add.__doc__ # 'Add two numbers.'
add.__wrapped__ # <function add at ...> -- the original, un-decorated function
functools.wraps(func) is itself a decorator (built on
functools.update_wrapper) that copies __name__, __doc__, __module__,
__qualname__, and __dict__ from func onto wrapper, and sets
wrapper.__wrapped__ = func so introspection tools can find the original
if needed.
Why this matters beyond cosmetics
- Debugging/tracebacks: stack traces show the wrapper's name unless metadata is copied, making it harder to tell which decorated function actually failed.
- Documentation tools (Sphinx,
help()) rely on__doc__/__name__to generate correct docs — withoutwraps, every decorated function's docs would show up as generic wrapper boilerplate. - Other decorators/frameworks that inspect
__name__(e.g., some testing or routing frameworks match handlers by function name) would silently break.
Interview-ready summary: A decorator's inner wrapper function shadows
the original function's identity (__name__, __doc__, etc.) unless you
explicitly restore it with @functools.wraps(func) — treat it as
mandatory boilerplate any time you write a decorator, not an optional
nicety.
Related Resources
Basic usage: memoizing an expensive pure function
import functools
@functools.lru_cache(maxsize=128)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
fib(35) # fast -- each unique n is computed once, then cached
fib.cache_info() # CacheInfo(hits=32, misses=36, maxsize=128, currsize=36)
Without caching, naive recursive fib is exponential (recomputes the same
sub-calls repeatedly). lru_cache turns it into linear time by
remembering every fib(n) result already computed — an "LRU" (least
recently used) eviction policy discards the oldest entries once maxsize
is exceeded (or never evicts, if maxsize=None).
Requirements and gotchas
- Arguments must be hashable — you can't cache a call with a
listordictargument, since the cache key is built from the arguments themselves (as if put in a dict). Pass tuples or frozensets instead. - Only for pure functions — the cache doesn't know if the function has side effects or depends on external mutable state; caching a function whose result depends on something other than its arguments (current time, a database row that can change) will return stale results.
- Memory:
maxsize=Nonecaches unboundedly — fine for a small, fixed input domain, dangerous for functions called with unbounded/unique arguments (e.g., caching by user-supplied string with no upper bound). - Caching instance methods:
@lru_cacheon a method caches by(self, *args), which keepsselfalive for as long as the cache entry exists — this can prevent garbage collection of instances you expected to be freed. A common workaround is caching a module-level function that takes only the needed hashable data instead of the wholeself.
cache vs lru_cache
@functools.cache # Python 3.9+, equivalent to lru_cache(maxsize=None)
def expensive(n):
...
functools.cache is a simpler alias for an unbounded cache, useful when
you know the input domain is naturally small/finite and don't need
eviction.
Interview-ready summary: lru_cache memoizes pure functions keyed by
their (hashable) arguments, turning repeated/recursive expensive
computation into cheap lookups — but it requires hashable arguments,
assumes no side effects or external state dependence, and can extend an
object's lifetime unexpectedly when applied to instance methods.
Related Resources
Basic usage
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
square(5) # 25
cube(5) # 125
square and cube are new callables with exponent pre-bound;
calling them only needs the remaining argument (base).
Equivalent lambda
square = lambda base: power(base, exponent=2)
Functionally similar for simple cases, but partial has real advantages:
- Introspectable:
square.func,square.args,square.keywordslet you inspect what's pre-filled — useful for debugging or building generic tooling around partially-applied callables. - Picklable:
partialobjects can be pickled (as long as the underlying function and bound arguments are), which matters formultiprocessing(worker functions passed to a process pool must be picklable) — alambdacannot be pickled at all. - Clearer intent:
partial(power, exponent=2)reads as "power, with exponent fixed at 2," which is more self-documenting than a lambda wrapping a call.
A common real use: multiprocessing/callback APIs
from functools import partial
from multiprocessing import Pool
def process(item, config):
return item * config["factor"]
with Pool() as pool:
results = pool.map(partial(process, config={"factor": 3}), [1, 2, 3])
Since worker functions passed across process boundaries must be picklable,
partial is the standard way to "bake in" extra arguments for a function
used as a callback, where a lambda would fail to pickle.
Interview-ready summary: partial pre-fills some of a function's
arguments and returns a new, real callable object — functionally similar
to a wrapping lambda, but introspectable and picklable, which matters for
tooling and multiprocessing in particular.