Python decorators
What a decorator actually is
A decorator is a callable that takes a function and returns a replacement callable. The replacement is usually a closure that captures the original and calls it.
from functools import wraps
def logged(func):
@wraps(func)
def wrapper(*args, **kwargs): # runs on every call
print(f"calling {func.__name__}")
return func(*args, **kwargs) # forward args, return the result
return wrapper # the decorator MUST return this
Two things an interviewer checks: the wrapper takes *args, **kwargs and returns func(...)'s value, and the decorator returns the wrapper. Drop the return and the decorated name becomes None, so the first call raises TypeError: 'NoneType' object is not callable.
@ syntax and what it desugars to
@logged above def greet is sugar for greet = logged(greet).
@logged
def greet(): ...
# identical to:
def greet(): ...
greet = logged(greet)
That assignment happens once, when the function is defined (at import time). The decorator body runs once; the returned wrapper runs on every call. A call counter belongs inside the wrapper, not in the decorator body, where it would only ever reach one.
functools.wraps and why you always add it
A wrapper is a separate function object, so by default it overwrites the original's identity.
Wrong "The decorator preserves the function, so greet.__name__ is still greet." Without help, the decorated greet.__name__ is 'wrapper', its __doc__ is gone, and there is no __wrapped__ link, which breaks help(), pydoc, logging, and pickling by name.
Right apply @wraps(func) to the wrapper. It copies __name__, __qualname__, __doc__, and __annotations__ from the original and sets __wrapped__ to point back at it.
Decorators that take arguments
A decorator that takes arguments is a function that returns a decorator, so @deco(arg) has three call layers.
def repeat(n): # (1) factory: takes the argument
def decorator(func): # (2) the real decorator: takes func
@wraps(func)
def wrapper(*args, **kwargs): # (3) wrapper: runs per call
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(): ...
# desugars to: greet = repeat(3)(greet)
repeat(3) runs first and returns decorator, then decorator(greet) returns wrapper. Both run once at definition time; n survives because the wrapper closes over it. Writing only two layers for @repeat(3) is the most common decorator bug: repeat(3) is then called with the argument alone and never receives the function.
functools.wraps copies metadata, not the signature
@wraps copies __name__, __qualname__, __doc__, __annotations__ (and __type_params__ since 3.12), merges __dict__, and sets __wrapped__ on the wrapper. Since 3.2 __wrapped__ is added automatically; since 3.4 it always points at the immediately wrapped function even when that function had one.
Wrong "With wraps the wrapper has the real signature." It does not. The wrapper still physically accepts (*args, **kwargs). wraps copies __annotations__ and sets __wrapped__; it never synthesizes a __signature__.
Right inspect.signature() recovers the original because it follows the __wrapped__ chain by default (follow_wrapped=True, added in 3.5). Pass follow_wrapped=False and it reports (*args, **kwargs) -> int: the parameter list is the wrapper's own (*args, **kwargs), proving no __signature__ was synthesized, while the -> int return annotation survives because @wraps copied __annotations__. Define a __signature__ on any layer and the walk stops there. inspect.unwrap() traverses the whole chain.
__wrapped__ is also an escape hatch: decorated.__wrapped__(...) calls the original directly and bypasses the wrapper. That is useful in tests and a security caveat for auth or cache gates.
Stacking order: build inward, run outward
@a
@b
def f(): ...
# f = a(b(f))
b wraps the original first (innermost), then a wraps the result. Application is bottom-up; execution is outside-in: at call time control enters a's wrapper, then b's, then f, and results and exceptions unwind in reverse. PEP 318 frames this as function composition, g(f(x)).
Class-based decorators and decorating a class
An instance is callable when its class defines __call__, so a class can serve as a decorator. Build state in __init__, put per-call logic in __call__, and copy metadata with functools.update_wrapper(self, func), since there is no @wraps sugar for self.
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func, self.calls = func, 0
def __call__(self, *args, **kwargs):
self.calls += 1 # per-function state lives on self
return self.func(*args, **kwargs)
One trap: a plain __call__ instance is not a descriptor, so decorating an instance method this way loses self binding. Use a closure-based decorator for methods, or implement __get__.
Decorating a class, @register class A, desugars to A = register(A); the callable receives the class object. A class decorator runs after the class is fully built and is not inherited, so each subclass must be re-decorated, whereas a metaclass propagates to subclasses.
lru_cache and cache pitfalls
@functools.lru_cache(maxsize=128, typed=False) memoizes recent calls; @functools.cache (added 3.9) is lru_cache(maxsize=None), unbounded. Both key the cache with a dict, so arguments must be hashable: a list or dict argument raises TypeError. With typed=False, 3 and 3.0 collide on one entry.
Wrong "@lru_cache on a method is a free per-instance cache." The cache is a single store on the function object and holds a strong reference to every self it has seen, so those instances are never collected. Prefer functools.cached_property, a module-level function, or a weakref-based cache.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.