Handbooks / Python / Chapter 4
Decorators & Closures
51 pages · ~99 min✓ Reviewed
Builds on Classes & Objects. Next up: Errors & Exceptions.
Part 1 · Decorators & Closures
Decorators & Closures: How Python Functions Capture State and Get Wrapped
Open almost any real Python codebase and you will meet @ lines above function definitions: @app.route, @pytest.fixture, @lru_cache, @login_required. Each one quietly changes what the function below it does, without touching its body. That trick is the reason web frameworks, test runners and caching layers read the way they do, and it rests on just two ideas: functions are ordinary objects you can pass around, and an inner function can remember variables from the function that made it. The second idea is called a closure, and the first is the reason a decorator can exist at all.
Most people learn decorators as a memorised template and then get stuck the first time something goes wrong: the docstring vanishes, every call returns None, a counter will not update, or @repeat without parentheses fails three lines away from the cause. This chapter builds the machinery from the ground up so those failures stop being mysterious. We start with functions as objects, move through scope and the LEGB lookup order, open up closure cells to see what is really captured, and only then show that @deco is plain rebinding, f = deco(f).
By the end you will be able to write a decorator with functools.wraps, forward arguments correctly, build parameterized and class-based decorators, predict the order in which stacked decorators run, and apply the patterns that show up in production: caching, retry with backoff, timing and auth checks. You will also know how to debug the usual bugs, using __wrapped__, __closure__ and inspect.unwrap, and when a decorator is the wrong tool.
You need Python 3.11 or newer and nothing beyond the standard library. You should be comfortable writing functions with default and keyword arguments, using *args and **kwargs, and reading a basic try/except. Run each example in a fresh file or the REPL, and keep a second terminal for experiments: poking at f.__name__ and f.__closure__ yourself is the fastest way to make these ideas stick.
Part 2 · Functions Are Objects
A Name Pointing at an Object
Everything in this chapter rests on one fact: in Python a function is an ordinary object, like a string or a list. Because it is an object, you can assign it to a variable, store it in a list or dict, pass it to another function as an argument, and return it from a function. Languages that allow all four are said to have first-class functions.
f = greet
the name is a label
[greet, bye]
{'csv': parse_csv}
sorted(xs, key=len)
function goes in
outer hands back inner
function comes out
A def statement does two things at once. It builds a function object, then binds that object to a name. After def greet(): ..., the bare name greet is the object itself, while greet() with parentheses is a call that runs the body and produces its return value. The parentheses are the only thing that triggers execution.
Since the name is only a reference, copying it copies no code. After f = greet, both names point at the very same function object, so f() runs exactly what greet() runs. The check f is greet proves it.
def greet(): return 'hi' f = greet print(f()) print(f is greet) print(f.__name__)
f is a second label on the same object, so its name is still greet
hi
True
greetFunctions can also live inside containers and travel through other functions. The next example stores two functions in a dict, passes one into a helper, and gets one back from another helper.
def shout(s): return s.upper() + '!' def whisper(s): return s.lower() + '...' styles = {'loud': shout, 'soft': whisper} for name, fn in styles.items(): print(name, fn('Hello')) def apply(fn, value): return fn(value) def pick(kind): return styles[kind] print(apply(shout, 'hey')) print(pick('soft')('HEY'))
loud HELLO! soft hello... HEY! hey...
Writing f = greet() stores the return value, not the function. Here f becomes the string 'hi', and calling it later fails. This is the most common beginner slip, and decorators need the object, so never add the parentheses when you mean the function itself.
def greet(): return 'hi' f = greet() print(f) try: f() except TypeError as e: print(e)
hi
'str' object is not callableWhat a Function Carries With It
A function object is not just code. It carries metadata you can read at runtime, and later chapters will lean on it heavily. These are the attributes worth knowing now.
| Attribute | Holds |
|---|---|
__name__ | the short name, such as 'greet' |
__qualname__ | the dotted path, which includes enclosing functions and classes |
__doc__ | the docstring, or None |
__module__ | the name of the module that defined it |
__defaults__ | a tuple of default argument values, or None |
__dict__ | a dict of any custom attributes attached to the function |
The difference between __name__ and __qualname__ shows up with nested functions. The qualified name records where the function was defined, which is what makes tracebacks and test reports readable.
def area(w, h=2): '''Rectangle area.''' return w * h def outer(): def inner(): pass return inner print(area.__name__) print(area.__doc__) print(area.__module__) print(area.__defaults__) print(area.__dict__) print(outer().__name__) print(outer().__qualname__)
area
Rectangle area.
__main__
(2,)
{}
inner
outer.<locals>.innerThat empty __dict__ is an invitation. Because every function has its own dictionary, you can attach arbitrary attributes to it with plain assignment. This is a handy way to keep a small piece of state, such as a call counter, on the function itself.
def greet(): return 'hi' greet.calls = 0 for _ in range(3): greet() greet.calls += 1 print(greet.calls) print(greet.__dict__)
3 {'calls': 3}
The lambda expression builds a function too. lambda x: x * 2 is an anonymous function object of exactly the same type as one made with def, namely types.FunctionType. The only difference is syntax: a lambda body is a single expression, with no statements, and its __name__ is the placeholder '<lambda>'.
import types def double(x): return x * 2 triple = lambda x: x * 3 print(type(double) is types.FunctionType) print(type(triple) is types.FunctionType) print(triple.__name__) print(double(4), triple(4))
True True <lambda> 8 12
| def | lambda | |
|---|---|---|
| Type | types.FunctionType | types.FunctionType |
| Body | any number of statements | one expression |
| Name | the name you chose | '<lambda>' |
| Docstring | allowed | not possible |
Higher-Order Functions and Callables
A higher-order function is one that takes a function as an argument, returns a function, or does both. You already use several built-ins of this kind: map applies a function to every item, filter keeps items for which a predicate is true, and sorted(key=...) orders items by the value a key function computes for each.
words = ['pear', 'fig', 'banana'] print(list(map(len, words))) print(list(filter(lambda w: len(w) > 3, words))) print(sorted(words, key=len))
len is passed without parentheses, because we want the function, not a call
[4, 3, 6] ['pear', 'banana'] ['fig', 'pear', 'banana']
You can write your own as well. The function below takes fn, defines a new function inside itself, and returns it. The returned function remembers fn and uses it every time it runs.
def twice(fn): def run(x): return fn(fn(x)) return run add3 = lambda x: x + 3 add6 = twice(add3) print(add6(10))
16Being a function is not the same as being callable. Any object whose class defines a __call__ method can be called with parentheses, and callable(obj) is the right test for that. An isinstance check against the function type would wrongly reject such objects.
class Twice: def __call__(self, x): return x * 2 t = Twice() print(callable(t), callable(len), callable(42)) print(isinstance(t, types.FunctionType)) print(t(5)) print(list(map(t, [1, 2])))
types was imported in the lambda example
True True False False 10 [2, 4]
A decorator is simply a function that receives a function and returns a replacement. That is only possible because functions are ordinary objects you can pass in and hand back out. Every later section in this chapter builds on that single idea.
Part 3 · Nested Functions & Scope (LEGB)
Functions inside functions, and where names are found
A def statement is not a declaration that exists once and for all. It is code that runs, and each time it runs it builds a new function object and binds it to a name. So when you put a def inside another function, the inner function is created fresh on every call of the outer function. Two calls to the outer function give you two separate inner functions, even though they come from the same source text.
def outer(): def inner(): return 'hi' return inner a = outer() b = outer() print(a is b) print(a() == b())
Same code, two distinct function objects
False True
The two inner functions behave the same, but they are different objects. That matters later, because each one gets its own private copy of the outer function's variables. Before that, you need to know how Python decides which variable a name refers to when the inner function runs.
Python looks a name up in a fixed order, remembered as LEGB: Local, Enclosing, Global, Built-in. It stops at the first scope that has the name. If none of the four has it, you get a NameError.
| Scope | What lives there | Example |
|---|---|---|
| Local | Parameters and names assigned inside the current function | z = 'local' inside inner |
| Enclosing | Names in an outer function, never the module | y defined in outer, read by inner |
| Global | Names assigned at the top level of the module | x defined at module level |
| Built-in | Names Python provides everywhere | len, print, ValueError |
The word enclosing trips people up. It means a surrounding def. A module is the global scope, not an enclosing one, and that difference will matter when we get to nonlocal. Here is one function that reads from all four levels at once.
x = 'global' def outer(): y = 'enclosing' def inner(): z = 'local' print(z, y, x, len(z)) inner() outer()
z is local, y is enclosing, x is global, len is built-in
local enclosing global 5Assignment decides scope
Reading a name is easy: Python walks LEGB until it finds it. Assigning is different. Any assignment to a name anywhere in a function body makes that name local to the whole function. That includes =, +=, for targets, import, and def. The rule applies to the entire body, so it even applies to lines that come before the assignment.
That is the source of a classic surprise. A function can read a global happily, but the moment you also assign to that name, the global is no longer visible inside the function. The earlier read now points at an empty local slot.
Writing total += 1 inside a function, when total lives outside, fails. += reads and then assigns, so total is local, and the read finds nothing. Python raises UnboundLocalError. Older versions word it as local variable 'total' referenced before assignment, and newer ones say cannot access local variable 'total' where it is not associated with a value. Same error, same cause.
This decision is made when the function is compiled, from the syntax alone. It does not depend on which branch runs or what values exist at runtime. The next example proves it: the assignment sits under if False, so it never executes, and the name is still local.
x = 1 def reads(): return x def assigns(): if False: x = 2 return x print(reads.__code__.co_varnames) print(assigns.__code__.co_varnames) try: assigns() except UnboundLocalError as e: print(type(e).__name__)
co_varnames lists the names the compiler decided are local
()
('x',)
UnboundLocalErrorreads has no local names, so x is looked up outward and found in the module. assigns has x as a local because of one unreachable line, so the later return x looks in the local slot, finds it empty, and fails. The compiler reads your syntax, not your runtime values.
Python decides whether a name is local by scanning the function body for assignments, before anything runs. If a function both reads and assigns a name from an outer scope, you must declare your intent with global or nonlocal, or use a different local name.
global, nonlocal, and mutation versus rebinding
When you really do want to assign to a name that lives elsewhere, you tell the compiler so. global x says that x is the module-level name, so assignments rebind it there. nonlocal x says that x belongs to the nearest enclosing function, so assignments rebind it there. Both change where a rebinding lands.
| Keyword | Rebinds | Fails when |
|---|---|---|
global x | The module-level name, even from a deeply nested def | Never: it creates the name at module level if missing |
nonlocal x | The nearest enclosing function's name | No enclosing function has that name |
The best-known use of nonlocal is a counter that keeps its state without a class. The outer function owns n, and the inner function rebinds it. Because the inner function is created fresh on every outer call, each counter gets its own n.
def outer(): n = 0 def inner(): nonlocal n n += 1 return n return inner c1 = outer() c2 = outer() print(c1(), c1(), c1()) print(c2())
Two counters, two independent values of n
1 2 3 1
nonlocal has a strict requirement: the name must already exist in an enclosing function. Python checks this at compile time, so a missing name is a SyntaxError, not a runtime error. A module-level variable does not count, because the module is global scope and not an enclosing function. We use compile() here so the error can be caught and printed.
src = ''' x = 1 def f(): nonlocal x ''' try: compile(src, '<demo>', 'exec') except SyntaxError as e: print(e.msg)
x exists at module level, but that is not enough
no binding for nonlocal 'x' found
One more point saves a lot of needless keywords. These declarations only matter when you rebind a name, which means pointing it at a different object. If you only mutate the object the name already refers to, Python never has to assign to the name, so it finds the outer list through normal LEGB lookup.
| Statement | Needs nonlocal or global? | Why |
|---|---|---|
lst.append(1) | No | Mutation: the name is only read |
d['k'] = 1 | No | Item assignment mutates the dict |
lst = [1] | Yes | Rebinding: creates a local name otherwise |
n += 1 | Yes | On an int it rebinds n |
def outer(): lst = [] def mutate(): lst.append(1) def rebind(): lst = [99] mutate() rebind() mutate() return lst print(outer())
rebind() only made a throwaway local list
[1, 1]
Writing lst = [1] inside a nested function does not raise an error. It quietly creates a new local list and leaves the outer one untouched. Reach for nonlocal lst, or mutate with lst[:] = [1] or lst.append(1).
Read freely, mutate freely, and add global or nonlocal only when the function body contains an assignment to the outer name.
Part 4 · Closures: What They Are & How They Work
A function that remembers
Normally a function's local variables vanish when it returns. A closure is the exception: it is a nested function bundled with the captured bindings it uses from the enclosing scope. Those bindings stay alive after the outer function has finished, so the inner function keeps working with them on every later call.
Not every nested function is a closure. Three things must all be true, and removing any one of them leaves you with something simpler.
| Condition | What it means | If it is missing |
|---|---|---|
| Nested function | An inner def (or lambda) sits inside an outer function | There is no enclosing function scope to capture from |
| References an outer name | The inner body reads a variable that belongs to the outer function | Nothing is captured, so the inner function is just an ordinary function |
| Outer returns the inner one | The inner function escapes the outer call as a return value | The captured names die with the outer call, so nothing observable outlives it |
The canonical example is a tiny factory. make_adder does not add anything itself; it builds and returns a new function that remembers the n it was given. Each call to make_adder produces a fresh add function.
def make_adder(n): def add(x): return x + n return add add5 = make_adder(5) add9 = make_adder(9) print(add5(3)) print(add9(3))
n is not a parameter of add, yet add can use it
8 12
By the time add5(3) runs, make_adder(5) returned long ago. The value 5 is still available because the returned function carries it along. The diagram shows how that survives.
- 1make_adder(5) startsn is created as a cell
- 2add is definedit holds a reference to that cell
- 3make_adder returns addthe call frame is gone
- 4The cell survivesadd keeps it reachable
- 5add5(3) runsreads n from the cell, returns 8
Opening the cells
Python lets you see the captured state directly. A function's __closure__ attribute is a tuple of cell objects, one per captured name, and each cell exposes its value as cell_contents. A plain function that captures nothing has __closure__ set to None.
The names are recorded in the code object. The inner function lists what it borrows in co_freevars, and the outer function lists what it hands out in co_cellvars. The two tuples describe the same name from the two sides.
print(type(add5.__closure__[0]).__name__) print(add5.__closure__[0].cell_contents) print(add9.__closure__[0].cell_contents) print(add5.__code__.co_freevars) print(make_adder.__code__.co_cellvars) print(add5.__closure__[0] is add9.__closure__[0])
reuses add5, add9 and make_adder from the previous example
cell 5 9 ('n',) ('n',) False
| Attribute | Lives on | Tells you |
|---|---|---|
__closure__ | the inner function | the actual cell objects, in the same order as co_freevars |
__closure__[0].cell_contents | a cell | the value the name currently holds |
__code__.co_freevars | the inner function | names it borrows from an enclosing scope |
__code__.co_cellvars | the outer function | local names that inner functions capture |
When a function seems to remember something it should not, print f.__code__.co_freevars to see which names it captured, then read f.__closure__[i].cell_contents to see their current values.
The last line of the output matters. add5 and add9 came from two separate calls, so their cells are two different objects. We come back to that on the next page.
Cells are shared, not copied
A closure captures a variable, not a snapshot of its value. The cell is a box the enclosing scope and the inner function both point at. Nothing is copied when the inner function is defined; the lookup happens when the inner function is called, so it sees whatever the cell holds at that moment.
Two consequences follow. A rebinding of the name after the def is visible inside the closure. And two inner functions that capture the same name from one call share a single cell, so one can change what the other sees.
def make_greeter(): word = 'hi' def greet(): return word word = 'hello' return greet def make_counter(): count = 0 def inc(): nonlocal count count += 1 return count def peek(): return count return inc, peek print(make_greeter()()) inc, peek = make_counter() inc() inc() print(peek()) print(inc.__closure__[0] is peek.__closure__[0])
hello 2 True
greet was defined while word was 'hi', yet it returns 'hello', because it reads the cell at call time. In the counter, inc and peek hold the very same cell, which is why peek reports the increments made by inc.
The classic loop bug
Sharing is exactly what bites people in loops. Every lambda created in a comprehension captures the same loop variable i. By the time you call any of them, the loop has finished and i holds its final value. There are two standard fixes: bind the current value as a default argument, which is evaluated at definition time, or build each function through a factory call so each one gets its own cell.
fs = [lambda: i for i in range(3)] print([f() for f in fs]) fixed = [lambda i=i: i for i in range(3)] print([f() for f in fixed]) def make_const(i): return lambda: i viaFactory = [make_const(i) for i in range(3)] print([f() for f in viaFactory])
[2, 2, 2] [0, 1, 2] [0, 1, 2]
| Approach | Why it works | Watch out for |
|---|---|---|
lambda i=i: i | The default value is evaluated immediately, so each lambda stores its own copy | Callers can accidentally override it by passing an argument |
| Factory function | Every call to the factory creates a brand new cell | One extra small function to write |
Writing [lambda: i for i in range(3)] and expecting 0, 1, 2. All three functions share one cell and return 2. Bind the value early with i=i or build each function in a factory.
Sharing only happens within one call of the outer function. Each call creates its own cells, so make_adder(5) and make_adder(9) never interfere, which is exactly what the False in the previous page's output showed.
Lifetime and when to use a class instead
A cell holds a reference, and a reference keeps its target alive. So a closure keeps everything it captured alive for as long as the closure itself exists. That is the whole point of closures, but it can surprise you when the captured object is large, such as a big list, a file handle or a database connection that you only needed briefly.
import weakref class Big: pass def make_reader(obj): def read(): return type(obj).__name__ return read big = Big() ref = weakref.ref(big) reader = make_reader(big) del big print(ref() is None) del reader print(ref() is None)
a weak reference lets us watch whether the object is still alive
False True
Even after del big, the object survives because reader holds it in a cell. Only when the closure is gone does the object get freed. If a closure needs just one field of a large object, capture that field instead of the whole object.
Closure or class?
A closure with one piece of state and one behaviour does the same job as an object with one attribute and one method. Here is the same running total written both ways.
def make_total(): total = 0 def add(x): nonlocal total total += x return total return add class Total: def __init__(self): self.total = 0 def __call__(self, x): self.total += x return self.total t1 = make_total() t2 = Total() print(t1(5), t1(2)) print(t2(5), t2(2)) print(t2.total)
5 7 5 7 7
Both work. The difference is visibility: t2.total is a named attribute anyone can read, while the closure's state is reachable only through __closure__. That trade-off decides which tool fits.
| Need | Reach for | Why |
|---|---|---|
| One behaviour, small hidden state | Closure | Short, no boilerplate, state is private by construction |
| Several methods sharing state | Class | Methods are named and organised in one place |
| Readable, inspectable state | Class | obj.total beats digging through __closure__ |
| A function to hand to decorators or callbacks | Closure | It is already just a function |
A closure is a function plus the cells it captured. Cells are shared by reference and read at call time, each call to the outer function makes new ones, and they keep their contents alive for as long as the closure lives.
Part 5 · Decorator Syntax: @ Is Just Sugar
The @ Line Is a Rebinding
The @ symbol looks like special syntax, but it does one small thing. Writing @deco on the line above a def means: run the def as normal, then immediately execute f = deco(f). The decorator receives the freshly built function object and whatever it returns becomes the new value of the name f.
- 1def f runsa function object is built
- 2deco(f) is calledthe object is passed in
- 3the result is returnedusually a wrapper
- 4f = resultthe name is rebound
Because it is only a rebinding, you can do the same thing by hand. The next example never uses @, yet it behaves exactly as a decorated function would. After the assignment, the name hello points at the wrapper, not at the function you wrote.
def shout(fn): def wrapper(*a, **kw): return fn(*a, **kw).upper() return wrapper def hello(): return 'hello' hello = shout(hello) print(hello()) print(hello.__name__) print(hello.__closure__[0].cell_contents())
Manual decoration: exactly what @shout would have done
HELLO wrapper hello
The last two lines show where everything went. The module-level name hello now belongs to wrapper. The original function is not gone, but it survives only as the closed-over variable fn, reachable through the wrapper's closure cell. If nothing else holds a reference to it, that cell is the only thing keeping it alive.
The Minimal Decorator and When Each Part Runs
A decorator is any callable that takes exactly one argument, the function, and returns a replacement. The smallest useful one defines an inner wrapper that accepts anything, passes everything through to fn, and hands the result back. The decorator returns the wrapper itself, not a call of it.
def deco(fn): print('decorating', fn.__name__) def wrapper(*a, **kw): print('calling', fn.__name__) return fn(*a, **kw) return wrapper @deco def greet(name): return 'hi ' + name print('defined') print(greet('Ana')) print(greet('Bo'))
Two different moments: decoration and calls
decorating greet defined calling greet hi Ana calling greet hi Bo
Notice that decorating greet appears before defined. The body of deco runs once, at definition (import) time, right after the def. The body of wrapper runs on every call.
| Code | Runs when | How often |
|---|---|---|
Body of deco | Right after the def executes (import time) | Once per decorated function |
Body of wrapper | Each time you call the decorated name | Every call |
Body of the original fn | Only if the wrapper calls it | Zero or more times per call |
The wrapper is also responsible for passing the result back. If it calls fn(...) but does not return that value, Python does not complain. The call just ends and the function returns None.
def broken(fn): def wrapper(*a, **kw): fn(*a, **kw) return wrapper @broken def add(a, b): return a + b print(add(2, 3))
The missing return in the wrapper
NoneForgetting return fn(*a, **kw) inside the wrapper makes every decorated call yield None, with no error and no traceback. If a decorated function suddenly returns None, check the wrapper's last line first.
Any Callable, Any Target
A decorator can be applied to anything you can write with def or class. The decorator just receives whichever object was just created and returns what should take its place.
| Target | What the decorator receives | What gets rebound |
|---|---|---|
| Module-level function | The function object | The module attribute |
| Nested function | A fresh function object, created on each outer call | The local name inside the outer function |
| Method inside a class | A plain function, before it becomes bound | The class attribute |
| Class | The class object | The class name |
The expression after @ does not have to be a bare name. Since PEP 614 (Python 3.9), it can be any expression that evaluates to a callable: a dict lookup, an attribute of an object, or a call that returns a decorator. Python evaluates the expression first, then applies the result to the function.
def loud(fn): def wrapper(*a, **kw): return fn(*a, **kw).upper() return wrapper def make_suffix(s): def deco(fn): def wrapper(*a, **kw): return fn(*a, **kw) + s return wrapper return deco class Bus: def __init__(self): self.subs = [] def on(self, fn): self.subs.append(fn) return fn styles = {'loud': loud} bus = Bus() @styles['loud'] def say(): return 'hey' @make_suffix('!') def cheer(): return 'go' @bus.on def log(msg): print('got', msg) print(say(), cheer()) bus.subs[0]('x')
@registry[name], @make_deco(x) and @obj.method
HEY go! got x
Not every decorator needs a wrapper. If the only goal is a side effect, such as recording the function somewhere, the decorator can do that and return fn unchanged. The name keeps pointing at the original function, so there is no extra call layer and no lost metadata.
REG = {}
def register(fn):
REG[fn.__name__] = fn
return fn
@register
def parse_csv(text):
return text.split(',')
@register
def parse_pipe(text):
return text.split('|')
print(sorted(REG))
print(REG['parse_csv'] is parse_csv)A decorator with no wrapper
['parse_csv', 'parse_pipe'] True
Nothing about @ is magic. It is a tidy place to write name = expr(name) so the wrapping is visible right at the definition.
Decorating an Already-Imported Function
Since a decorator is just a function, you do not need to be the author of the def to use one. For code you imported rather than wrote, apply the decorator by hand and assign the result back onto the module: mod.f = deco(mod.f). From then on, anyone who looks up mod.f gets the wrapped version. This is called monkeypatching, and it is how you add logging or timing to a library function without editing its source.
import statistics from statistics import mean as old_mean def logged(fn): def wrapper(*a, **kw): print('calling', fn.__name__) return fn(*a, **kw) return wrapper statistics.mean = logged(statistics.mean) print(old_mean([1, 2, 3])) print(statistics.mean([1, 2, 3]))
Patching a stdlib function with the same decorator shape
2 calling mean 2
The second call goes through the wrapper, but the first does not. old_mean was bound to the original function before the patch, and rebinding statistics.mean does not reach names that were already copied elsewhere.
Code that did from mod import f before you patched keeps the original function. Patch before such imports run, or patch the name in each module that holds a copy. Also remember the patch is global: every caller of mod.f in the process now pays for your wrapper.
Part 6 · Preserving Metadata with functools.wraps
What a Bare Wrapper Erases
A decorator replaces your function with another one. The name greet now points at the wrapper, so every question you ask about greet is answered by the wrapper's attributes, not by the function you wrote. The wrapper was born inside the decorator, so it reports the decorator's idea of itself: it is called wrapper, it lives at naive.<locals>.wrapper, and it carries whatever docstring the wrapper had, which is usually none.
def naive(fn): def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper @naive def greet(name, punct="!"): """Say hello.""" return "hi " + name + punct print(greet.__name__) print(greet.__qualname__) print(greet.__doc__) print(hasattr(greet, "__wrapped__"))
Nothing here is broken at call time, yet the identity is gone.
wrapper naive.<locals>.wrapper None False
The function still works, which is exactly why this bug survives code review. The damage shows up later, in every tool that asks a function who it is.
| Tool or habit | What it reads | Without wraps |
|---|---|---|
help(f) and pydoc | __name__, __doc__, signature | Documents wrapper with no description |
| Sphinx autodoc | __name__, __doc__, __wrapped__ | Every decorated function is listed as wrapper, with no docs |
| pytest | __name__ of the collected object | Test ids all read wrapper, and tests can collide |
| Logging and repr | __qualname__, __module__ | Log lines and reprs name naive.<locals>.wrapper |
| Registries keyed by name | fn.__name__ | Many routes or handlers land on the same key wrapper |
A decorator without @wraps raises no error and passes every functional test. You only notice when help() shows wrapper, when two registered handlers overwrite each other under one name, or when your test report is unreadable.
The Fix: @functools.wraps
The cure is one line. Put @functools.wraps(fn) directly on the inner wrapper, passing the function being wrapped. It copies the identity of fn onto the wrapper, so the wrapper can stand in for it without introducing itself as someone else.
Under the hood, wraps(fn) is only a convenience. It returns functools.partial(update_wrapper, wrapped=fn), so using @wraps(fn) on the wrapper means exactly wrapper = update_wrapper(wrapper, fn). The work happens in a few fixed steps.
- 1Copy attributesfor each name in WRAPPER_ASSIGNMENTS: setattr(wrapper, name, getattr(fn, name))
- 2Merge dictionariesfor each name in WRAPPER_UPDATES: wrapper.dict.update(fn.dict)
- 3Link backwrapper.wrapped = fn
- 4Return the wrapperthe same object, now carrying fn's identity
| Attribute | How wraps treats it | Result on the wrapper |
|---|---|---|
__module__ | assigned (copied) | module where fn was defined |
__name__ | assigned (copied) | 'greet' |
__qualname__ | assigned (copied) | 'greet', not deco.<locals>.wrapper |
__doc__ | assigned (copied) | the original docstring |
__dict__ | updated, never replaced | the wrapper's own attributes plus those of fn |
__wrapped__ | set to fn | a pointer to the function that was wrapped |
The word updated matters for __dict__. The wrapper keeps any attribute it already had, and the attributes of fn are merged on top. That is how an attribute set by an inner decorator, such as tagged below, survives an outer one. Newer Python versions also copy __annotations__ (and later __type_params__) as part of the assigned names, which is why you should treat the tuples as a moving list rather than memorise them.
import functools def logged(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper def tag(fn): fn.tagged = True return fn @logged @tag def greet(name, punct="!"): """Say hello.""" return "hi " + name + punct print(greet.__name__, greet.__qualname__) print(greet.__doc__) print(sorted(greet.__dict__)) print(greet.__wrapped__.__name__) print(functools.WRAPPER_UPDATES) print("__doc__" in functools.WRAPPER_ASSIGNMENTS)
The attribute tagged was set on the original by tag, then merged onto the wrapper.
greet greet Say hello. ['__wrapped__', 'tagged'] greet ('__dict__',) True
WRAPPER_ASSIGNMENTS and WRAPPER_UPDATES are plain tuples, and wraps accepts assigned= and updated= to override them for a single decorator. Narrowing them is rare, but it is the supported way to say that you want only some of the identity copied. __wrapped__ is always set, whatever you pass.
def name_only(fn): @functools.wraps(fn, assigned=("__name__",), updated=()) def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper @name_only def f(): """Original doc.""" print(f.__name__, f.__doc__) print(f.__wrapped__.__doc__)
f None
Original doc.Following wrapped Back to the Original
The last step of update_wrapper stores the original in wrapper.__wrapped__. That one attribute makes the decorator chain traversable, and two standard tools build on it. inspect.signature(f) follows __wrapped__ and reports the original signature, which is what a person or an IDE wants to see. Pass follow_wrapped=False when you really want the wrapper's own (*args, **kwargs).
import inspect print(inspect.signature(greet)) print(inspect.signature(greet, follow_wrapped=False)) print(greet.__code__.co_argcount, greet.__code__.co_varnames) try: greet() except TypeError as e: print(e)
Uses greet from the previous example.
(name, punct='!') (*args, **kwargs) 0 ('args', 'kwargs') greet() missing 1 required positional argument: 'name'
Look closely at the last two lines, because they show what wraps does not do. It copies references and a pointer, nothing more. The wrapper's own code object still takes args and kwargs, and that is what actually binds your call. The nice signature is a report, never a check: the TypeError above comes from the original function when the wrapper forwards the call to it, not from the wrapper.
When several decorators are stacked, each one adds a layer with its own __wrapped__. Walking the pointers by hand works, but inspect.unwrap does the whole walk and returns the first function that has no __wrapped__, which is the undecorated one.
def add(a, b): return a + b original = add add = logged(logged(add)) print(add.__name__) print(add.__wrapped__ is original) print(add.__wrapped__.__wrapped__ is original) print(inspect.unwrap(add) is original)
add False True True
inspect.unwrap(f) gives you the raw function, which is handy for calling the logic in a test without the caching, retry or auth layer around it. It only works if every layer used wraps: one decorator that forgot it breaks the chain at that point.
Limits, Failure Modes and the Rule
Because wraps only copies attributes, some things it can never fix. A traceback prints the name stored in the wrapper's code object, and wraps does not touch that, so a failing call still shows a frame in wrapper even though greet.__name__ is 'greet'. The same goes for the argument list: only the reported signature changes, not what the wrapper accepts.
| Question | Answered by | Fixed by wraps? |
|---|---|---|
| What is this function called? | __name__, __qualname__ | Yes |
| What does its docstring say? | __doc__ | Yes |
| What signature does inspect report? | __wrapped__ | Yes |
| What parameters does the wrapper really bind? | __code__ of the wrapper | No, still *args, **kwargs |
| What name does a traceback frame show? | __code__.co_name | No, still wrapper |
The other edge is wrapping something that is not an ordinary function. A functools.partial object, a callable instance, or a C function may not have __name__ or __qualname__. Modern update_wrapper skips attributes that are missing on the wrapped object instead of failing, but then the wrapper silently keeps its own name wrapper. The AttributeError usually arrives from your own decorator, the moment it reads fn.__name__ for a log message.
p = functools.partial(int, base=2) print(hasattr(p, "__name__")) w = logged(p) print(w.__name__) print(w("101")) try: print(p.__name__) except AttributeError as e: print(type(e).__name__)
wraps tolerates the missing name; reading it yourself does not.
False wrapper 5 AttributeError
So when a decorator might receive a partial or a callable object, read names defensively with getattr(fn, "__name__", repr(fn)). For a class instance used as a decorator, call functools.update_wrapper(self, fn) instead of the @wraps form.
Writing @wraps(wrapper), or calling wraps(fn) without applying it to the inner function, leaves the identity unchanged or copies it backwards. The argument is always the function being wrapped, and the decorator line always sits on the inner wrapper.
Every decorator that returns a new function gets @wraps(fn) on that function. It costs one line, it makes help, docs, test names, logs and inspect tell the truth, and a decorator that skips it breaks unwrap for every layer above it. There are no exceptions to decide about.
Part 7 · Passing Arguments Through the Wrapper
Collect, Then Unpack
A decorator replaces your function with a wrapper, so every caller now talks to the wrapper first. The wrapper does not know in advance what the original function accepts. The safe default is to take anything and relay it unchanged. That is the universal forwarding signature: def wrapper(*args, **kwargs): return fn(*args, **kwargs).
The same two symbols do opposite jobs depending on where they sit. In a def line, *args collects every extra positional argument into a tuple, and **kwargs collects every keyword argument into a dict. In a call line, *args and **kwargs unpack that tuple and dict back into separate arguments. It is one syntax pointing in two directions.
| Where it appears | *args does | **kwargs does |
|---|---|---|
In def wrapper(...) | packs positionals into a tuple | packs keywords into a dict |
In fn(...) | spreads the tuple into positionals | spreads the dict into keywords |
The wrapper below prints what it collected, then unpacks it into the real function. Notice that port was never passed, so it is absent from both args and kwargs. The original function fills in its own default.
from functools import wraps def spy(fn): @wraps(fn) def wrapper(*args, **kwargs): print("args:", args) print("kwargs:", kwargs) return fn(*args, **kwargs) return wrapper @spy def connect(host, port=5432, *, ssl=False): return f"{host}:{port} ssl={ssl}" print(connect("db", ssl=True))
args: ('db',) kwargs: {'ssl': True} db:5432 ssl=True
A forwarding wrapper accepts any call, passes it on untouched, and returns whatever the original returned. Anything beyond that is behaviour you add around the relay.
Reading and Editing Arguments
Real decorators often need to look at one argument, for example a user for an auth check. The tempting move is args[0], but that guesses how the caller wrote the call. The same function can be called as delete_post(7, "ana") or delete_post(7, user="ana"), and in the second case user is not in args at all. On a method, args[0] is self, not your first declared parameter.
If the caller used a keyword, args[0] is the wrong value or raises IndexError. For methods it is self. Both bugs hide until someone calls the function in a different style.
The reliable tool is inspect.signature(fn).bind(*args, **kwargs). It matches the incoming call against the real parameter list and produces one mapping, whichever style the caller used. Calling apply_defaults() adds parameters the caller left out, so bound.arguments['user'] always exists.
import inspect from functools import wraps def require_user(fn): sig = inspect.signature(fn) @wraps(fn) def wrapper(*args, **kwargs): bound = sig.bind(*args, **kwargs) bound.apply_defaults() user = bound.arguments["user"] if not user: raise PermissionError("no user") print("user is", user) return fn(*args, **kwargs) return wrapper @require_user def delete_post(post_id, user="guest"): return f"post {post_id} deleted by {user}" print(delete_post(7, user="ana")) print(delete_post(8, "bo")) print(delete_post(9))
Keyword, positional and omitted all land in the same place
user is ana post 7 deleted by ana user is bo post 8 deleted by bo user is guest post 9 deleted by guest
Sometimes you want to change an argument before the call. args is a tuple and tuples are immutable, so you cannot assign into it. Rebuild it instead: args = (args[0].strip(),) + args[1:]. After the call, the result is just a value, so you can capture it and transform it before returning: result = fn(*args, **kwargs) then return transform(result).
from functools import wraps def tidy(fn): @wraps(fn) def wrapper(*args, **kwargs): args = (args[0].strip(),) + args[1:] result = fn(*args, **kwargs) return result.upper() return wrapper @tidy def greet(name, punct="!"): return f"hello, {name}{punct}" print(greet(" ada ")) print(greet(" bo ", punct="?"))
Fine here because name is always passed first; greet(name='x') would raise IndexError
HELLO, ADA! HELLO, BO?
| Goal | Where it happens | Technique |
|---|---|---|
| Read a parameter by name | before the call | bind, apply_defaults, then arguments[name] |
| Change an argument | before the call | rebuild args or edit the kwargs dict |
| Change the return value | after the call | store result, then return a transformed copy |
Async, Generators and partial
A plain def wrapper around an async def function is a quiet trap. Calling it returns the coroutine without awaiting it, so the caller gets a coroutine object where the wrapper's own work should have finished, or sees RuntimeWarning: coroutine was never awaited. The wrapper has to match the kind of function it wraps.
| Wrapped function | Wrapper must be | Forwarding line |
|---|---|---|
plain def | plain def | return fn(*args, **kwargs) |
async def | async def | return await fn(*args, **kwargs) |
| generator | generator def | return (yield from fn(*args, **kwargs)) |
For a generator, the wrapper must itself contain yield from, which makes it a generator and hands each yielded value through. Its own code runs lazily, on the first next(). Writing return (yield from ...) also passes along the generator's final return value.
import asyncio from functools import wraps def log_async(fn): @wraps(fn) async def wrapper(*args, **kwargs): print("awaiting", fn.__name__) return await fn(*args, **kwargs) return wrapper def log_gen(fn): @wraps(fn) def wrapper(*args, **kwargs): print("streaming", fn.__name__) return (yield from fn(*args, **kwargs)) return wrapper @log_async async def fetch(n): await asyncio.sleep(0) return n * 2 @log_gen def countdown(n): while n: yield n n -= 1 print(asyncio.run(fetch(21))) print(list(countdown(3)))
awaiting fetch 42 streaming countdown [3, 2, 1]
Not every need for forwarding calls for a wrapper. If you only want to pre-fill some arguments and add no behaviour around the call, use functools.partial. It builds a new callable with arguments already bound. There is no wrapper function body, no extra frame of your own code, and no metadata to preserve.
from functools import partial def power(base, exp): return base ** exp square = partial(power, exp=2) two_to = partial(power, 2) print(square(9)) print(two_to(10)) print(square.func.__name__, square.keywords)
81 1024 power {'exp': 2}
The Narrow Wrapper Trap
The last pitfall is writing a wrapper that only mentions the parameters you happen to be using today. A signature like def wrapper(x) accepts exactly one positional argument. The original function may accept more, and every caller who passes an extra positional argument or any keyword argument now fails inside the wrapper, before your function is reached.
def narrow(fn): def wrapper(x): return fn(x) return wrapper @narrow def area(w, h=1): return w * h try: area(3, h=4) except TypeError as e: print(e)
narrow.<locals>.wrapper() got an unexpected keyword argument 'h'The error names wrapper, not area, which makes it confusing to trace. The original function accepted h perfectly well. The decorator broke the contract. Switching the wrapper to (*args, **kwargs) restores it.
Writing def wrapper(x) shrinks the function's accepted signature to one positional argument. Extra positionals and all keyword arguments raise TypeError. Use *args, **kwargs and read specific values with bind only when you truly need them.
Collect with *args, **kwargs and unpack the same way in the call. Read named values through inspect.signature(...).bind, never args[0]. Rebuild tuples to edit them, and transform the result after the call. Match async and generator functions with async and generator wrappers. Reach for functools.partial when you only pre-bind.
Part 8 · Decorators That Take Arguments
A Decorator Factory Returns a Decorator
Sometimes a decorator needs settings: how many times to repeat, how long to wait, which role to require. The @ line then carries parentheses, as in @repeat(3). This is not a new kind of syntax. Python evaluates the expression after the @ first, and whatever comes back is used as the decorator. So @repeat(3) means f = repeat(3)(f): repeat(3) is called first and must return a decorator, and that decorator is then called with your function.
- 1repeat(3)the factory runs and receives the settings
- 2returns decoa plain decorator that remembers n
- 3deco(f)the decorator receives your function
- 4returns wthe wrapper becomes the new f
That gives three nested levels, and each one has exactly one job. The outermost function takes configuration, the middle one takes the function being decorated, and the innermost one takes the arguments of each call. Every level except the last returns the level below it.
| Level | Name | Receives | Returns | Runs |
|---|---|---|---|---|
| 1 | factory, repeat(n) | the settings | the decorator | once per @repeat(...) line |
| 2 | decorator, deco(fn) | the function | the wrapper | once per decorated function |
| 3 | wrapper, w(*a, **kw) | the call arguments | the function's result | every time the function is called |
Here is the complete shape. It is a plain decorator with @wraps and argument forwarding, wrapped inside one more function that holds n. The last two lines show the layers from outside: the decorated name still reads ping, while repeat(2) on its own is just the middle-level decorator, named deco.
from functools import wraps def repeat(n): def deco(fn): @wraps(fn) def w(*a, **kw): for _ in range(n): r = fn(*a, **kw) return r return w return deco @repeat(3) def ping(): print('pong') ping() print(ping.__name__) print(repeat(2).__name__)
Three levels, and every level returns the one below it
pong pong pong ping deco
When you write a factory, check that return deco ends the outer function and return w ends the middle one. Forgetting either leaves you with a decorated name that is None or the wrong callable.
How the Settings Reach Every Call
The wrapper w never receives n as a parameter, yet it uses it on every call. This works through the closure from earlier sections. n is a local of repeat, and w reads it as a free variable, so the cell holding n stays alive after repeat returns. Each @repeat(...) line calls the factory again and so creates a fresh cell, which means two functions decorated with different counts never interfere.
calls = [] @repeat(2) def a(): calls.append('a') @repeat(4) def b(): calls.append('b') a() b() print(calls)
Each decoration gets its own n
['a', 'a', 'b', 'b', 'b', 'b']
The wrapper sees two captured names, fn from the middle level and n from the outer one. Both are fixed at decoration time. The loop variable and r are ordinary locals created fresh on each call, and r holds the result of the last repetition, which is what the caller gets back.
The classic mistake: forgetting the parentheses
If repeat needs configuration, then @repeat without a call applies the factory as though it were the decorator. The function you meant to decorate is passed in as n. Nothing checks that, because the factory only stores n and returns deco. Decoration succeeds, and the name ping2 is now bound to deco, which still waits for a function.
@repeat # wrong: should be @repeat(3) def ping2(): print('pong') print(ping2.__name__) try: ping2() except TypeError as e: print(type(e).__name__)
The decorator line succeeds silently
deco TypeError
The failing line is the call ping2(), or on a different day an ndarray complaining inside range(n), while the real mistake is the decorator line at import time. When a decorated function suddenly wants an unexpected argument, check that its name is not deco and that the @ line has its parentheses.
Validate Early and Accept Both Forms
Check the settings when the factory runs
The factory runs at import time, once per @ line, so that is the place to reject bad settings. If you wait for the wrapper to fail, the error arrives on the first call, possibly hours later and far from the decorator that caused it. Raising inside the factory puts the traceback on the decorator line itself. A cheap check also catches the missing-parentheses bug above, because the function arrives where an integer was expected.
def repeat(n): if not isinstance(n, int): raise TypeError('repeat(n) needs an int, did you forget the call?') if n < 1: raise ValueError('repeat(n) needs n >= 1') def deco(fn): @wraps(fn) def w(*a, **kw): for _ in range(n): r = fn(*a, **kw) return r return w return deco for bad in (len, 0): try: repeat(bad) except (TypeError, ValueError) as e: print(type(e).__name__, e)
Failing at factory time, not first call
TypeError repeat(n) needs an int, did you forget the call?
ValueError repeat(n) needs n >= 1Supporting @deco and @deco(x=1) together
Some libraries let users write the decorator bare when the defaults are fine and with parentheses when they are not. The trick is to make the function argument optional. When the decorator is called as @deco(label='audit'), no function arrives, so fn is None, and the decorator hands back a partial of itself with the settings already filled in. Python then applies that partial to the function. When the decorator is used bare, fn is the function and the wrapper is built right away.
from functools import partial, wraps def log_calls(fn=None, *, label='call'): if fn is None: return partial(log_calls, label=label) @wraps(fn) def w(*a, **kw): print(f'[{label}] {fn.__name__}') return fn(*a, **kw) return w @log_calls def one(): return 1 @log_calls(label='audit') def two(): return 2 print(one(), two())
Both spellings reach the same wrapper
[call] one [audit] two 1 2
The signature def deco(fn=None, *, x=1) is what keeps the two shapes from being confused. The bare * makes every option keyword-only, so users must write x=2 and a stray positional value cannot be mistaken for a setting. On Python 3.8 and later you can also write def deco(fn=None, /, *, x=1), which additionally makes the function positional-only so that it can never arrive by keyword.
| @deco | @deco(x=2) | |
|---|---|---|
What arrives as fn | the function | nothing, so fn is None |
What deco returns | the finished wrapper | partial(deco, x=2) |
| Who then sees the function | deco directly | the partial, which calls deco(fn, x=2) |
State, Reuse and Real-World Shapes
The factory runs once per @ line, and the decorator it returns runs once per function it is applied to. That makes the decorator object a poor place for shared data. If you create it once with audit = track() and apply it to several functions, anything defined in the factory body belongs to all of them. A mutable default such as a list or dict made there is shared by every function decorated with that object. State that should belong to one function must be created inside the decorator body, which runs again for each function.
def track_shared(): seen = [] # factory scope: one list for everyone def deco(fn): @wraps(fn) def w(*a, **kw): seen.append(fn.__name__) return fn(*a, **kw) w.seen = seen return w return deco def track_own(): def deco(fn): seen = [] # decorator scope: one list per function @wraps(fn) def w(*a, **kw): seen.append(fn.__name__) return fn(*a, **kw) w.seen = seen return w return deco shared, own = track_shared(), track_own() @shared def f1(): pass @shared def g1(): pass @own def f2(): pass @own def g2(): pass f1(); g1(); f2(); g2() print(f1.seen, g1.seen) print(f2.seen, g2.seen)
Where the list is created decides who shares it
['f1', 'g1'] ['f1', 'g1'] ['f2'] ['g2']
Putting seen = [], a cache dict or a counter in the factory body, then reusing one decorator object on several functions, makes those functions silently share it. Create per-function state inside the decorator, and use the factory only for settings.
Factories you already use
Once you recognize the three-level shape, a lot of library code reads easily. In every case the part with parentheses is a factory call that returns the real decorator, and the arguments only configure it.
| Spelling | What the parentheses configure | Equivalent to |
|---|---|---|
@lru_cache(maxsize=128) | how many results the cache keeps | f = lru_cache(maxsize=128)(f) |
@app.route('/path') | the URL rule that will run f | f = app.route('/path')(f) |
@pytest.mark.parametrize(...) | the argument names and test cases | f = parametrize(...)(f) |
@retry(times=3, delay=1) | attempts and pause between them | f = retry(times=3, delay=1)(f) |
A plain decorator takes a function and returns a replacement. A decorator factory takes settings and returns that plain decorator. If a decorator takes any configuration, write the parentheses, validate the settings in the factory, and keep per-function state out of factory scope.
Part 9 · Class-Based Decorators & Decorating Classes
A Class as a Decorator
A decorator is any callable that takes a function and returns a replacement. A function is not the only kind of callable. Any object whose class defines __call__ can be called like a function, so a class can play the decorator role too. When you write @Counted above a def, Python runs Counted(fn). That builds an instance, and the instance becomes the new value of the name.
The class needs two methods. __init__(self, fn) runs once at decoration time and stores the function. __call__(self, *args, **kwargs) runs on every call and forwards to the stored function, just as a wrapper would.
- 1def greet(name): ...plain function object is created
- 2Counted(greet)init stores fn, sets count = 0
- 3greet = <Counted instance>the name now points at the instance
- 4greet('Ada')instance.call runs, then fn runs
The main reason to choose a class is that state becomes explicit. A function-based decorator keeps a counter in a closure cell and has to rebind it with nonlocal. A class keeps it as an attribute on the instance. You can read it from outside, reset it, or inspect it in a test, because it is just greet.count.
| Closure wrapper | Class-based decorator | |
|---|---|---|
| Where state lives | A cell captured by the wrapper | An attribute on the instance |
| Updating a counter | nonlocal count then count += 1 | self.count += 1 |
| Reading it from outside | Dig through __closure__ | greet.count |
| Best for | One behaviour, no state to expose | Named per-function state |
There is one identity detail. @wraps(fn) is a decorator for functions, so it cannot be applied to an instance. Call functools.update_wrapper(self, fn) inside __init__ instead. It copies __name__, __doc__, __qualname__ and __module__ onto the instance and sets __wrapped__. In fact, @wraps(fn) is only a thin shell around this same call.
import functools class Counted: def __init__(self, fn): self.fn = fn self.count = 0 functools.update_wrapper(self, fn) def __call__(self, *args, **kwargs): self.count += 1 return self.fn(*args, **kwargs) @Counted def greet(name): '''Say hello.''' return f'hi {name}' print(greet('Ada')) print(greet('Bo')) print(greet.count) print(greet.__name__, greet.__doc__)
The count is an ordinary attribute, and the metadata survived.
hi Ada
hi Bo
2
greet Say hello.Writing @wraps(fn) above __init__ or __call__ does not do what you want, because it decorates the method and not the instance. The decorated name keeps reporting the class name until you call update_wrapper(self, fn) inside __init__.
The Method Problem and Its Fix
Class-based decorators work well on plain functions and break on methods. The reason lies in how Python binds methods. A real function stored in a class defines __get__, so obj.method triggers that hook and produces a bound method with obj already filled in as self. A Counted instance has no __get__. Attribute lookup therefore hands back the Counted instance untouched, and calling it never passes the object.
The fix is to make the decorator class a descriptor by giving it __get__(self, obj, objtype). Return functools.partial(self.__call__, obj), which pre-binds the instance as the first argument, so self.fn receives it as self. When the method is looked up on the class itself, obj is None. Returning the decorator in that case keeps Greeter.hello.count reachable.
class Greeter: @Counted def hello(self, name): return f'hello {name}' try: Greeter().hello('Ada') except TypeError: print('broken: self was never passed') class CountedMethod(Counted): def __get__(self, obj, objtype=None): if obj is None: return self return functools.partial(self.__call__, obj) class Greeter: @CountedMethod def hello(self, name): return f'hello {name}' a, b = Greeter(), Greeter() print(a.hello('Ada')) print(b.hello('Bo')) print(Greeter.hello.count)
Reuses Counted from the previous example.
broken: self was never passed
hello Ada
hello Bo
2Notice that the count is 2 even though two different objects made the calls. The decorator instance belongs to the function, which is shared by every instance of the class. That suits a per-function counter. For state that must be per object, the decorator would need to store it keyed on obj.
If the decorator only needs to be applied to methods, a function-based decorator is simpler. Functions already carry __get__, so the returned wrapper is bound automatically. Choose a class when you need named per-function state, and add __get__ only if that class will decorate methods.
A class-based decorator that works on module-level functions can fail the moment someone applies it to a method. The error usually reads like a missing argument, far from the real cause, which is that the instance never became self.
Decorating a Class
Decorators can also sit above a class statement. The rule is the same one you already know: @deco followed by class C: ... means C = deco(C). The decorator receives the finished class object and whatever it returns becomes the name C. That gives it three options. It can mutate the class and return it, it can return a subclass that extends it, or it can replace it with something else entirely. Mutating and returning the same class is by far the most common, because it keeps isinstance checks and inheritance behaving as expected.
registry = {}
def register(cls):
registry[cls.__name__] = cls
return cls
def add_repr(cls):
def __repr__(self):
fields = ', '.join(f'{k}={v!r}' for k, v in vars(self).items())
return f'{cls.__name__}({fields})'
cls.__repr__ = __repr__
return cls
@register
@add_repr
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
print(Point(1, 2))
print(registry)One decorator mutates the class, the other only records it.
Point(x=1, y=2) {'Point': <class '__main__.Point'>}
Stacked class decorators follow the same bottom-up order as function decorators. add_repr runs first and patches in __repr__, then register records the result. The standard library ships several class decorators that you will use constantly.
| Decorator | What it does to the class | Needs from you |
|---|---|---|
@dataclass | Generates __init__, __repr__ and __eq__ from annotated fields | Annotated class attributes |
@functools.total_ordering | Fills in the missing comparison methods | __eq__ plus one of __lt__, __le__, __gt__, __ge__ |
@typing.runtime_checkable | Lets isinstance check a Protocol by method names | A Protocol class |
import io from dataclasses import dataclass from functools import total_ordering from typing import Protocol, runtime_checkable @dataclass class Item: name: str qty: int = 1 @total_ordering class Version: def __init__(self, n): self.n = n def __eq__(self, other): return self.n == other.n def __lt__(self, other): return self.n < other.n @runtime_checkable class Closable(Protocol): def close(self): ... print(Item('pen', 3)) print(Version(2) >= Version(1)) print(isinstance(io.StringIO(), Closable)) print(isinstance(5, Closable))
Item(name='pen', qty=3) True True False
A function decorator wraps behaviour that runs on each call. A class decorator runs once, right after the class body finishes, so it is the place to add methods, register the class or validate its shape.
Built-in Method Decorators and cached_property
Python's own method decorators are all descriptors. They change how the attribute is looked up and what gets passed in. @staticmethod removes the implicit first argument, so the function behaves like a plain one that merely lives in the class namespace. @classmethod passes the class as the first argument, conventionally named cls, which makes it suitable for alternate constructors. @property turns a method into attribute-style access, so you write obj.diameter with no parentheses.
| Decorator | First argument | How you use it |
|---|---|---|
@staticmethod | none | C.f() or obj.f() |
@classmethod | cls | C.f() or obj.f() |
@property | self | obj.f, no call parentheses |
@functools.cached_property | self | obj.f, computed once per instance |
@functools.cached_property is a property that computes its value on the first access and then stores the result in the instance's __dict__ under the same name. Because a plain instance attribute now shadows the descriptor, later reads skip the function entirely. This only works when the instance has a __dict__, so a class that declares __slots__ without one cannot use it. Access raises a TypeError instead.
Ordering matters when you stack your own decorator with these built-ins. @classmethod and @staticmethod must be the outermost decorator, because they return a descriptor object and not a plain function. Your own decorator needs a real function to wrap, so it goes underneath. Reversing the order hands your wrapper a classmethod object, which is not callable, and the call fails.
from functools import cached_property, wraps def logged(fn): @wraps(fn) def wrapper(*a, **kw): print('calling', fn.__name__) return fn(*a, **kw) return wrapper class Circle: def __init__(self, r): self.r = r @property def diameter(self): return self.r * 2 @cached_property def area(self): print('computing') return 3 * self.r ** 2 @classmethod @logged def unit(cls): return cls(1) @staticmethod def is_valid(r): return r > 0 c = Circle.unit() print(c.diameter, Circle.is_valid(-1)) print(c.area) print(c.area) print('area' in c.__dict__) class Slim: __slots__ = ('r',) def __init__(self, r): self.r = r @cached_property def area(self): return self.r try: Slim(2).area except TypeError: print('TypeError')
classmethod sits outside logged. The value is computed once and lands in __dict__.
calling unit 2 False computing 3 3 True TypeError
The word computing appears only once even though c.area was read twice, and 'area' is now a key in c.__dict__. The second read never reached the function.
Putting @logged above @classmethod makes the wrapper receive a classmethod object instead of a function. Using @cached_property on a __slots__ class fails at the first access. If you want caching on a slotted class, store the value in a slot yourself.
Part 10 · Stacking Order & Execution Flow
Applied Bottom-Up, Executed Top-Down
When you put several decorators above one def, Python does not apply them in reading order. Each @ line is shorthand for a rebinding, so a stack is just nested function calls. The decorator closest to the def receives the real function first. The one above it receives whatever that first decorator returned, and so on up the stack.
- 1def fthe real function object
- 2b(f)applied first, innermost
- 3a(b(f))applied last, outermost
- 4f = a(b(f))the name f now points at a's wrapper
Calling f() later runs in the opposite direction. The name f points at the outermost wrapper, which is a's. That wrapper does its before-work, then calls what it closed over, which is b's wrapper. b does its before-work and finally calls the real function. The after-work then unwinds back out in reverse: b finishes first, then a.
The program below makes both directions visible. Each decorator prints a line at the moment it wraps (the application phase) and lines when its wrapper runs (the call phase). Because of @wraps, fn.__name__ still reads f at every layer, so the messages stay readable.
from functools import wraps def tag(name): def deco(fn): print(f'wrapping {fn.__name__} with {name}') @wraps(fn) def wrapper(*args, **kwargs): print(f'enter {name}') result = fn(*args, **kwargs) print(f'exit {name}') return result return wrapper return deco print('-- defining') @tag('a') @tag('b') def f(): print('real f') return 42 print('-- calling') f()
Two phases: wrapping happens once, entering and exiting happens on every call
-- defining wrapping f with b wrapping f with a -- calling enter a enter b real f exit b exit a
| Application (definition time) | Execution (call time) | |
|---|---|---|
| When it happens | Once, as the def statement finishes | On every call of f() |
| Order | Bottom to top: b then a | Top to bottom: a then b then real f |
| Which code runs | The decorator body, deco(fn) | The wrapper body |
| Unwinding | Not applicable | Reverse: b exits before a |
The decorator closest to the def wraps first, so it ends up innermost. The decorator highest in the file wraps last, so it is outermost and runs first on a call.
Look at the output again: both wrapping lines appeared before -- calling, and no call was needed. Every side effect inside a decorator body, such as registering, printing or opening a connection, runs as soon as the module is imported, and it runs in bottom-up order. Only the wrapper bodies wait for a call. If a decorator factory like @tag('a') has its own side effect, that outer call is evaluated top-down just before the bottom-up wrapping begins.
When Order Changes the Result
If two decorators never touch each other's behaviour, swapping them changes nothing visible. The trouble starts when their effects interact. The cleanest example is caching combined with logging. A cache that sits above the logger answers repeat calls without ever reaching the logger, so repeats leave no log line. A logger that sits above the cache sees every call, including the ones the cache answers instantly.
from functools import wraps def log(fn): @wraps(fn) def wrapper(*args): print(f'log: {fn.__name__}{args}') return fn(*args) return wrapper def cache(fn): store = {} @wraps(fn) def wrapper(*args): if args not in store: store[args] = fn(*args) return store[args] return wrapper @cache @log def square(n): return n * n @log @cache def cube(n): return n ** 3 print(square(3), square(3)) print(cube(3), cube(3))
Same two decorators, opposite order, different logs
log: square(3,) 9 9 log: cube(3,) log: cube(3,) 27 27
| Stack (top to bottom) | What you observe | Use it when |
|---|---|---|
@cache over @log | Only cache misses are logged; repeats are silent | You want a log of real work |
@log over @cache | Every call is logged, hits included | You want a log of traffic |
@timer over @retry | One time covering all attempts and any sleeps | You care about total latency for the caller |
@retry over @timer | One time per attempt | You want to spot slow individual attempts |
Timing and retrying follow the same logic. A timer placed outside a retry decorator starts its clock before the first attempt and stops after the last one, so it reports the total elapsed time including every failure and pause. A timer placed inside the retry decorator is re-entered on each attempt, so it reports a separate number per attempt. The next example uses a fake clock that advances two seconds per attempt, which keeps the output exact. The function fails twice and then succeeds.
from functools import wraps clock = 0.0 attempts = 0 def timer(fn): @wraps(fn) def wrapper(*args, **kwargs): start = clock try: return fn(*args, **kwargs) finally: print(f'timer {fn.__name__}: {clock - start:.0f}s') return wrapper def retry(times): def deco(fn): @wraps(fn) def wrapper(*args, **kwargs): for attempt in range(times): try: return fn(*args, **kwargs) except IOError: if attempt == times - 1: raise return wrapper return deco def work(): global clock, attempts clock += 2 attempts += 1 if attempts % 3: raise IOError('down') return 'ok' @timer @retry(3) def fetch_total(): return work() @retry(3) @timer def fetch_each(): return work() print(fetch_total()) print(fetch_each())
The timer's position decides whether it sees one long call or three short ones
timer fetch_total: 6s ok timer fetch_each: 2s timer fetch_each: 2s timer fetch_each: 2s ok
Registration and Built-in Decorators Go Outermost
Some decorators do not wrap behaviour. They hand the function to somebody else, for example a URL router, a plugin registry or a test collector. Whatever they store is the object they received at that moment. If another decorator is applied afterwards, it creates a new function that the registry never sees. So a registering decorator such as @app.route has to be the outermost one: only then does it receive the fully decorated function.
from functools import wraps routes = {} def route(path): def deco(fn): routes[path] = fn return fn return deco def shout(fn): @wraps(fn) def wrapper(*a, **kw): return fn(*a, **kw).upper() return wrapper @shout @route('/hi') def hi_wrong(): return 'hello' @route('/bye') @shout def bye(): return 'goodbye' print(routes['/hi']()) print(routes['/bye']())
/hi registered the raw function; /bye registered the shouting wrapper
hello GOODBYE
With @route below @login_required or @shout, the route is registered with the undecorated function. The page works, but the auth check or the transformation is silently skipped in production. Put the registering decorator at the very top.
The built-in descriptors follow a similar rule, but for a different reason. @classmethod and @property do not produce ordinary functions. They produce objects that only work when the class looks them up. A function-style decorator placed above them tries to call the object directly, and that object is not callable. The built-ins therefore go outermost, with your own decorators underneath them, closest to the def.
from functools import wraps def noisy(fn): @wraps(fn) def wrapper(*a, **kw): return fn(*a, **kw) return wrapper try: class Bad: @noisy @classmethod def make(cls): return cls.__name__ Bad.make() except TypeError as e: print(e) class Good: @classmethod @noisy def make(cls): return cls.__name__ print(Good.make())
The same two decorators, only the order differs
'classmethod' object is not callable
Good| Decorator | Position in a stack | Why |
|---|---|---|
@app.route(...) | Outermost | Registration must see the final function |
@staticmethod | Outermost | Your wrapper expects a plain callable, which sits underneath it |
@classmethod | Outermost | Its object is a descriptor and cannot be called directly |
@property | Outermost, over its getter | The class must see a property object, not a wrapper function |
| Logging, timing, caching, auth | Underneath the ones above | They wrap the plain function |
Debugging a Stack and What It Costs
When behaviour looks wrong, first confirm what the chain actually is. Every decorator that uses @wraps leaves a __wrapped__ attribute pointing one layer down. So f.__wrapped__ is the next layer and f.__wrapped__.__wrapped__ is the one below it. You can follow the links by hand, or call inspect.unwrap(f), which follows them all the way to the original function and stops when there is no more __wrapped__. The raw function has none, which is how you know you have reached the bottom.
The same example measures what a layer costs. Each wrapper is an ordinary Python function, so every call through it adds one more frame to the call stack. The helper depth() walks the frames from where it runs up to the top, so comparing a three-layer function with a plain one counts the extra frames exactly.
import inspect, sys from functools import wraps def passthrough(fn): @wraps(fn) def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper def depth(): frame, count = sys._getframe(), 0 while frame: count += 1 frame = frame.f_back return count def plain(): return depth() @passthrough @passthrough @passthrough def deep(): return depth() print('extra frames:', deep() - plain()) g, layers = deep, 0 while hasattr(g, '__wrapped__'): g = g.__wrapped__ layers += 1 print('layers:', layers) print('raw is deep:', g is deep) print('unwrap agrees:', inspect.unwrap(deep) is g)
Walk the chain by hand, then confirm with inspect.unwrap
extra frames: 3 layers: 3 raw is deep: False unwrap agrees: True
Three layers added three frames, and the layer count matched the number of @ lines. That is the full price of a stack: one frame and one round of argument packing per layer on every call. For I/O-bound work the cost vanishes next to the real call. In a tight loop it shows up in a profiler as many near-identical wrapper entries. In a traceback it shows up as extra lines for each layer the exception passes through, which is why @wraps and short stacks make failures much easier to read.
| Question | Tool | Answer you get |
|---|---|---|
| How many layers are there? | Follow f.__wrapped__ repeatedly | One link per decorated layer |
| What is the original function? | inspect.unwrap(f) | The undecorated function |
Did a layer forget @wraps? | Check for __wrapped__ on each layer | The chain stops early at the culprit |
| Why does the profile look noisy? | Count wrapper entries | One per layer per call |
If a log line appears before the check that should have blocked the call, or a cached value is stale, the stack order is usually the cause. Print the chain and read it from the outside in, which is the top of the file downwards. That is the order in which a call travels.
Part 11 · Common Uses in Practice
Caching, Timing and Tracing
Most decorators you will ever write fall into a handful of jobs. Each one wraps a call, does something before or after it, and leaves the function body alone. We start with the three that touch every call: remembering results, measuring time, and recording what happened.
Memoization with lru_cache
Memoization stores a function's result keyed by its arguments, so a repeated call returns instantly instead of recomputing. The standard library ships it as @functools.lru_cache(maxsize=None), and @functools.cache is the same unbounded cache with shorter spelling. The decorated function gains two helpers: .cache_info() reports hits, misses and size, and .cache_clear() empties the cache.
The cache is a dict under the hood, so every argument must be hashable. Pass a list and you get a TypeError before your function even runs.
import functools @functools.lru_cache(maxsize=None) def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) print(fib(30)) print(fib.cache_info()) fib.cache_clear() print(fib.cache_info()) try: fib([1, 2]) except TypeError as e: print(e)
Each fib(n) is computed once; the other calls are hits
832040 CacheInfo(hits=28, misses=31, maxsize=None, currsize=31) CacheInfo(hits=0, misses=0, maxsize=None, currsize=0) unhashable type: 'list'
| @cache / maxsize=None | @lru_cache(maxsize=128) | |
|---|---|---|
| Memory | Grows without limit | Capped, least recently used entry is dropped |
| Best for | Small, finite input space | Long-running code with many distinct inputs |
| Argument rule | Hashable only | Hashable only |
@lru_cache on a method puts self into every cache key, so the cache keeps each instance alive forever. Cache a module-level function instead, or use cached_property for a per-instance value.
Timing and tracing
A timing decorator reads a clock before the call, reads it again after, and logs the difference. The clock matters: time.perf_counter() is a monotonic, high-resolution timer built for measuring durations, while time.time() is the wall clock and can jump when the system clock is adjusted.
| time.perf_counter() | time.time() | |
|---|---|---|
| Meaning | Elapsed-time counter | Current wall-clock date and time |
| Can jump backwards | No | Yes, on clock changes |
| Use it for | Durations | Timestamps in logs |
Logging is the most common hand-rolled decorator of all. A good one records the function name, the arguments, the result, and any exception, then re-raises so the caller still sees the failure. The example below does that and measures the duration in the same wrapper.
import functools, time def traced(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): shown = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]) print(f"call {fn.__name__}({shown})") start = time.perf_counter() try: result = fn(*args, **kwargs) except Exception as exc: print(f"error {fn.__name__}: {type(exc).__name__}: {exc}") raise else: print(f"result {result!r}") return result finally: wrapper.last_elapsed = time.perf_counter() - start return wrapper @traced def divide(a, b=1): time.sleep(0.02) return a / b divide(10, b=4) print(divide.last_elapsed >= 0.01) try: divide(1, 0) except ZeroDivisionError: print("caller saw the error")
call divide(10, b=4) result 2.5 True call divide(1, 0) error divide: ZeroDivisionError: division by zero caller saw the error
If the wrapper catches an exception to log it and does not raise again, the decorated call quietly returns None and the caller never learns it failed.
Retry, Gates and Limits
The next group of decorators decides whether a call should happen at all, or happen again. They run logic around the call and either let it through, repeat it, or refuse it.
Retry with backoff
A retry decorator loops up to attempts times. It catches a specific tuple of exceptions (never a bare except), waits delay * 2**i seconds so each pause doubles, and re-raises on the last attempt so the real error reaches the caller.
- 1Call fntry the real function
- 2Success?return the result
- 3Last attempt?yes: re-raise the error
- 4Sleep delay * 2**ithen loop again
import functools, time def retry(attempts=3, delay=0.01, exceptions=(ConnectionError,)): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): for i in range(attempts): try: return fn(*args, **kwargs) except exceptions as exc: if i == attempts - 1: raise wait = delay * 2 ** i print(f"attempt {i + 1} failed ({exc}); sleeping {wait:.2f}s") time.sleep(wait) return wrapper return decorator calls = {"n": 0} @retry(attempts=3, delay=0.01) def flaky(): calls["n"] += 1 if calls["n"] < 3: raise ConnectionError("reset") return "ok" print(flaky()) @retry(attempts=2, delay=0.01) def always_down(): raise ConnectionError("down") try: always_down() except ConnectionError as exc: print("gave up:", exc)
attempt 1 failed (reset); sleeping 0.01s attempt 2 failed (reset); sleeping 0.02s ok attempt 1 failed (down); sleeping 0.01s gave up: down
Catching Exception retries bugs such as a TypeError or a bad password, which can never succeed. List only the transient errors, like connection and timeout failures.
Gates: auth, validation and rate limiting
A gate decorator checks a condition first and only then delegates. An auth gate looks at the current user and raises PermissionError (a web framework would return a 403 response instead). A validation gate uses inspect.signature to pair each argument with its annotation and raises TypeError early, before the function body does anything half-finished.
A rate limiter needs memory between calls, which is exactly what a closure provides. The version below keeps the time of the last accepted call in a nonlocal variable and ignores calls that arrive too soon. A token bucket works the same way, with a counter that refills over time instead of a single timestamp.
import functools, inspect, time current_user = {"name": "ana", "roles": {"viewer"}} def require_role(role): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): if role not in current_user["roles"]: raise PermissionError(f"{current_user['name']} lacks role {role!r}") return fn(*args, **kwargs) return wrapper return decorator def check_types(fn): sig = inspect.signature(fn) @functools.wraps(fn) def wrapper(*args, **kwargs): bound = sig.bind(*args, **kwargs) for name, value in bound.arguments.items(): expected = fn.__annotations__.get(name) if isinstance(expected, type) and not isinstance(value, expected): raise TypeError(f"{name} must be {expected.__name__}, got {type(value).__name__}") return fn(*args, **kwargs) return wrapper def min_interval(seconds): def decorator(fn): last = None @functools.wraps(fn) def wrapper(*args, **kwargs): nonlocal last now = time.monotonic() if last is not None and now - last < seconds: return None last = now return fn(*args, **kwargs) return wrapper return decorator @require_role("admin") def delete_user(name): return f"deleted {name}" @check_types def repeat(text: str, times: int): return text * times @min_interval(60) def save_draft(): return "saved" try: delete_user("bo") except PermissionError as exc: print("denied:", exc) print(repeat("ab", 2)) try: repeat("ab", "2") except TypeError as exc: print("bad call:", exc) print(save_draft(), save_draft())
denied: ana lacks role 'admin' abab bad call: times must be int, got str saved None
Because the check happens in the wrapper, the real function never runs for a refused call. That is what makes gates safe: no partial work, no side effects.
Registration, Context Managers and Dispatch
Registration
Not every decorator wraps anything. A registration decorator such as @register('csv') stores the function in a plugin dictionary and then returns the very same function unchanged. Nothing is added to later calls, so there is no extra frame and no lost metadata. The effect happens once, at import time.
contextmanager: a generator becomes a with-block
@contextlib.contextmanager turns a generator function into a context manager. Code before the yield runs on entering the with block, the yielded value becomes the as target, and code after the yield runs on exit. Wrap the yield in try/finally so the cleanup still runs when the block raises.
import contextlib PARSERS = {} def register(name): def decorator(fn): PARSERS[name] = fn return fn return decorator @register("csv") def parse_csv(text): return text.split(",") @register("pipe") def parse_pipe(text): return text.split("|") print(sorted(PARSERS)) print(PARSERS["csv"]("a,b,c")) print(parse_pipe is PARSERS["pipe"]) @contextlib.contextmanager def announce(label): print(f"enter {label}") try: yield label.upper() finally: print(f"leave {label}") with announce("db") as name: print("inside", name)
['csv', 'pipe'] ['a', 'b', 'c'] True enter db inside DB leave db
singledispatch: one name, many types
@functools.singledispatch turns a function into a family of overloads that is chosen by the type of the first argument. The decorated function is the fallback. You add branches with @fn.register, either reading the type from the parameter annotation or passing it explicitly, as in @show.register(list).
import functools @functools.singledispatch def show(x): return f"object {x!r}" @show.register def _(x: int): return f"int {x}" @show.register(list) def _(x): return "list of " + ", ".join(show(i) for i in x) print(show(3)) print(show("hi")) print(show([1, "a"]))
int 3 object 'hi' list of int 1, object 'a'
Only the first positional argument picks the branch. If you need to choose on the second argument too, use a plain isinstance check inside the function.
The Same Ideas in Frameworks
Once you recognise these shapes, framework decorators stop looking magic. Each one is a registration, a gate, or a factory built on the patterns above.
| Framework | Decorator | What it does |
|---|---|---|
| Flask | @app.route('/path') | Registers the function as a handler for a URL, like @register |
| pytest | @fixture | Registers the function as a named setup resource tests can request |
| Django | @login_required | Auth gate that redirects before the view runs |
| Click | @click.command | Turns the function into a command-line command |
Here is how the whole chapter's toolbox maps to the jobs you will meet. Reach for the stdlib version first, and hand-roll a decorator when the behaviour is specific to your code.
| Job | The wrapper does | Ready-made? |
|---|---|---|
| Caching | Looks up arguments, skips the call on a hit | functools.cache, lru_cache |
| Timing | Reads perf_counter before and after | Write your own |
| Logging | Records name, args, result, errors | Write your own |
| Retry | Loops, sleeps, re-raises on the last try | Write your own |
| Auth / validation | Checks first, then delegates | Frameworks, or write your own |
| Rate limiting | Closure keeps last call time or tokens | Write your own |
| Registration | Stores the function, returns it unchanged | Write your own |
| Dispatch | Chooses by first argument's type | functools.singledispatch |
Decorators fit concerns that repeat across many functions: caching, timing, logging, retry, auth and registration. The actual business logic stays inside the function body.
Part 12 · Comparisons & Trade-offs
Decorators Versus the Alternatives
Most of the time a decorator is not the only way to add behavior to a function. Python gives you at least five neighbors: a manual wrapper call, a class-based decorator, a mixin or subclass, a context manager, and framework middleware. Each one wins in a particular situation, so this page puts them side by side.
Decorator syntax versus a manual wrapper call
@deco above a def is exactly f = deco(f) run right after the definition. The @ form is declarative: anyone reading the definition sees the wrapping right where the function is written. The manual form is explicit, and it is the only one that works on a function you did not define yourself, such as something imported from another module.
import math from functools import wraps def logged(fn): @wraps(fn) def wrapper(*args, **kwargs): print(f'calling {fn.__name__}{args}') return fn(*args, **kwargs) return wrapper @logged def double(x): return x * 2 logged_sqrt = logged(math.sqrt) # no def of our own to put @ on print(double(4)) print(logged_sqrt(16))
Same decorator, two ways to apply it
calling double(4,) 8 calling sqrt(16,) 4.0
The same trick patches an imported name in place: mod.f = deco(mod.f). Use it when you cannot edit the source, for example when instrumenting a library function in a test.
| @deco | f = deco(f) | |
|---|---|---|
| Where it reads | Right at the definition | Wherever the assignment sits |
| Works on imported objects | No, you must own the def | Yes |
| Original still reachable | Only through __wrapped__ | Yes, if you keep another name |
| Best for | Your own functions | Third-party or one-off wrapping |
Closure decorator versus class decorator
A closure decorator keeps its state in hidden cells, which makes it short and tends to make it pickle-friendlier. A class decorator keeps state in named attributes and can offer methods, so you can inspect or reset it. Neither is more powerful; they differ in how visible the state is.
from functools import wraps, update_wrapper def count_calls(fn): calls = 0 @wraps(fn) def wrapper(*args, **kwargs): nonlocal calls calls += 1 return fn(*args, **kwargs) return wrapper class CountCalls: def __init__(self, fn): update_wrapper(self, fn) self.fn = fn self.calls = 0 def __call__(self, *args, **kwargs): self.calls += 1 return self.fn(*args, **kwargs) @count_calls def ping(): return 'pong' @CountCalls def hello(): return 'hi' ping(); ping(); ping() hello(); hello() print(ping.__code__.co_freevars) print(ping.__closure__[0].cell_contents) # state hidden in a cell print(hello.calls) # state under a name
Same counter, two homes for the state
('calls', 'fn') 3 2
| Closure decorator | Class decorator | |
|---|---|---|
| Size | Terse, one function | More lines, one class |
| State | Hidden in cells, reached with nonlocal | Named attributes such as self.calls |
| Extra methods | None | Easy: reset(), stats() |
| Inspection | Dig through __closure__ | Plain attribute access |
| On methods | Works as is | Needs __get__ to receive self |
Decorator versus mixin or subclass
Inheritance ties a behavior to a type hierarchy: to share a mixin, the classes must be able to inherit from it, and the behavior applies to the whole class. A decorator composes horizontally. The same @audited can sit on a method, a plain module function and a lambda-built callable that have nothing in common, and it applies only where you put it. Reach for a mixin when the behavior really belongs to a kind of object and needs to override methods or hold per-instance state.
| Decorator | Mixin or subclass | |
|---|---|---|
| Reaches | Any callable, unrelated functions | Only classes in the hierarchy |
| Granularity | One function at a time | Every method of the class |
| Setup cost | None, just add a line | Design the hierarchy first |
| Fits when | The concern is cross-cutting | Behavior belongs to a type |
Decorator versus context manager
A decorator wraps a whole function. A with statement wraps an arbitrary block, which can be three lines in the middle of a function. If a concern needs both shapes, contextlib.ContextDecorator gives you one class that works as @announce(...) and as with announce(...).
from contextlib import ContextDecorator class announce(ContextDecorator): def __init__(self, label): self.label = label def __enter__(self): print(f'start {self.label}') return self def __exit__(self, *exc): print(f'end {self.label}') return False @announce('whole function') def job(): print('working') job() with announce('one block'): print('inside block')
One class, both shapes
start whole function working end whole function start one block inside block end one block
Decorator versus middleware
In a web framework, middleware sits once in the request pipeline and sees every request. A decorator sits on one handler and sees only that handler's calls. Middleware buys you a single place to look; decorators buy you granularity, such as requiring a login on three views out of forty.
| Middleware | Decorator | |
|---|---|---|
| Scope | Whole pipeline | One handler |
| Declared | In one config place | Beside each handler |
| Good for | Request IDs, CORS, compression | Per-route auth, caching, rate limits |
What Decorators Cost
A decorator is not free, and the bill comes in three currencies: runtime, memory and clarity. None of these is a reason to avoid decorators, but you should know the size of each before putting one on a hot path.
Runtime: one frame and some argument packing
Every call to a decorated function goes through the wrapper first. That adds one extra Python frame and the cost of packing *args, **kwargs into a tuple and a dict, then unpacking them again. For a function that waits on a network or a disk this is noise. In a tight numeric loop that calls a tiny function millions of times, it is measurable, so time it before you assume. The example counts frames instead of seconds so the result is exact.
import sys, traceback from functools import wraps def passthrough(fn): @wraps(fn) def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper def frames(): n, f = 0, sys._getframe() while f: n += 1 f = f.f_back return n print(passthrough(frames)() - frames()) print(passthrough(passthrough(frames))() - frames()) @passthrough def fail(): raise ValueError('boom') try: fail() except ValueError: print([f.name for f in traceback.extract_tb(sys.exc_info()[2])])
Each layer adds a frame, and the traceback shows it
1 2 ['<module>', 'wrapper', 'fail']
Debuggability: wrapper frames in every traceback
The last line of that output is the debugging cost. The traceback now contains a wrapper frame that your own code never mentions, and stepping through with a debugger means stepping through the wrapper too. @wraps helps because names, docstrings and __wrapped__ stay correct, which makes logs and help() readable. It does not remove the extra frames. A stack of four decorators means four extra frames in every error report.
Memory: what lru_cache really trades
functools.lru_cache turns a repeated computation into an O(1) dictionary lookup on a hit. The price is memory. With maxsize=None the cache never evicts, so it grows with every distinct argument. The cache keys also hold strong references to the arguments, so an object passed in stays alive for as long as its entry does, even if the rest of the program has dropped it.
import gc, weakref from functools import lru_cache class Big: pass @lru_cache(maxsize=2) def square(n): return n * n for n in (1, 2, 1, 3, 1): square(n) print(square.cache_info()) @lru_cache(maxsize=None) def size(obj): return 1 b = Big() ref = weakref.ref(b) size(b) del b gc.collect() print(ref() is None) # the cache still holds it size.cache_clear() gc.collect() print(ref() is None) # released once the cache is cleared
Bounded eviction, and a key that keeps an object alive
CacheInfo(hits=2, misses=3, maxsize=2, currsize=2) False True
| You gain | You pay |
|---|---|
| O(1) lookup on a hit | Memory that grows without bound at maxsize=None |
| No recomputation | Keys keep their arguments alive |
Free stats via cache_info() | Every argument must be hashable |
The cache key includes self, so every instance that calls the method is pinned in memory for as long as the cache lives. Use a bounded maxsize, cached_property, or a per-instance cache instead.
Typing: a bare wrapper hides the signature
A wrapper declared as (*args, **kwargs) tells a type checker nothing, so the decorated function loses its parameter and return types and wrong calls pass unnoticed. PEP 612 fixes this with ParamSpec, which carries the original parameters through, and Concatenate, which lets a decorator add or remove leading parameters.
from typing import Callable, Concatenate, ParamSpec, TypeVar from functools import wraps P = ParamSpec('P') R = TypeVar('R') def keep_signature(fn: Callable[P, R]) -> Callable[P, R]: @wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) return wrapper def with_conn(fn: Callable[Concatenate[str, P], R]) -> Callable[P, R]: @wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn('conn-1', *args, **kwargs) return wrapper @keep_signature def add(a: int, b: int) -> int: return a + b @with_conn def fetch(conn: str, key: int) -> str: return f'{conn}:{key}' print(add(2, 3)) print(fetch(7))
The checker still sees add(a: int, b: int) and fetch(key: int)
5 conn-1:7
At runtime these annotations change nothing. The benefit shows up in your editor and in the type checker, which can now reject add('x') and fetch() with the right message.
Choosing: Decorator or Explicit Call
The costs above are small and worth paying when a decorator removes real repetition. They are not worth paying for a single use. The deciding question is whether the concern is cross-cutting (it has nothing to do with what the function computes) and repeated (it applies to many functions).
If the timing, retry or permission check happens in exactly one place, a plain call or a with block is easier to read than a decorator defined somewhere else. A reader sees the behavior where it happens and never has to open a second function to learn what the first one does.
| Situation | Prefer | Why |
|---|---|---|
| Same auth check on 20 handlers | Decorator | One definition, visible on each handler |
| Retry around one flaky network call | Explicit call | Used once, so keep it in view |
| Timing a block inside a function | with block | A decorator can only wrap the whole function |
| Wrapping a library function you import | f = deco(f) | You cannot put @ on code you do not own |
| Needs named state and reset methods | Class decorator | State is inspectable |
| Behavior tied to a family of types | Mixin or subclass | It belongs to the type, not to a function |
Writing a decorator, a factory and a wrapper to use them on exactly one function adds three levels of indirection and saves nothing. Start with an explicit call and promote it to a decorator when the second and third copies appear.
Decorators carry cross-cutting concerns that repeat: logging, caching, retry, auth, timing, registration. Business logic stays inside the function. Pick @ for declarative reuse, f = deco(f) for code you do not own, a class for named state, a with block for part of a function, and middleware for one global pipeline stage.
Part 13 · Common Mistakes & Debugging
The Silent Failures
Most decorator bugs do not crash at the decorator line. The program imports cleanly, and the damage shows up later, somewhere that looks unrelated. Four of them come up again and again: a missing @wraps, a missing return inside the wrapper, a missing return wrapper at the end of the decorator, and the wrong choice between @deco and @deco().
Forgetting @wraps
A wrapper without @wraps replaces the original function's identity. Calls still work, so nothing complains. But help(), Sphinx, log lines, tracebacks and pytest test ids all read wrapper instead of the real name, and the docstring is gone.
The other three mistakes are in the program below. silent forgets the inner return, so every call gives back None. forgot builds the wrapper but never returns it, so the decorated name itself becomes None. ping is decorated with a factory, @repeat, but without the parentheses. One program shows all four, and the output shows what each one costs you.
from functools import wraps def bare(fn): def wrapper(*a, **kw): return fn(*a, **kw) return wrapper def silent(fn): def wrapper(*a, **kw): fn(*a, **kw) # no return return wrapper def forgot(fn): def wrapper(*a, **kw): return fn(*a, **kw) # no 'return wrapper' def repeat(n): def deco(fn): @wraps(fn) def w(*a, **kw): for _ in range(n): r = fn(*a, **kw) return r return w return deco @bare def one(): """Say one.""" return 1 @silent def two(): return 2 @forgot def three(): return 3 @repeat # should be @repeat(3) def ping(): return "pong" print(one.__name__, one.__doc__) print(two()) print(three) print(ping.__name__) try: ping() except TypeError as e: print(e)
wrapper None None None deco repeat.<locals>.deco() missing 1 required positional argument: 'fn'
Why the parentheses bug blames the wrong line
@repeat means ping = repeat(ping). The function lands in the parameter n, and repeat happily returns deco. So ping is now bound to deco, which expects a function. Decoration succeeds, and the error only fires when ping() is called. The traceback points at the call site, but the cause is the decorator line. The opposite slip, writing @deco() on a plain decorator, fails sooner. It calls deco with no function, which gives the same missing 1 required positional argument message right at import time.
| Mistake | What you see | Fix |
|---|---|---|
No @wraps | __name__ is 'wrapper', docstring is None | Put @wraps(fn) on every wrapper |
No return in the wrapper | Every call gives None | return fn(*a, **kw) |
No return wrapper | The decorated name is None; calling it raises TypeError: 'NoneType' object is not callable | End the decorator with return wrapper |
@repeat for a factory | Name becomes deco; error appears at the first call | Write @repeat(3) |
@deco() for a plain decorator | TypeError: deco() missing 1 required positional argument at import | Write @deco |
If a decorator takes any configuration, it is a factory and needs parentheses. When a call fails oddly, print f.__name__ right after import. If it says deco or wrapper where you expected your own function name, check the line that decorates it.
A wrapper with no return never raises an error. It quietly turns every result into None. Treat return fn(*a, **kw) as part of the template and never as an optional extra.
State in the Wrong Place
Closures capture names, not values, and that has two consequences. A loop variable is shared by every closure created in the loop. And a variable defined at the wrong nesting level is shared by every function that goes through that level. Both bugs come down to asking which scope owns the variable.
Late binding in loops
Every closure built in a loop shares one cell for the loop variable. The cell is read when the closure is called, not when it is created. By then the loop has finished and the variable holds its last value. There are two fixes. Bind the current value now with a default argument, or build each closure through a factory function so that every call gets its own cell.
Factory scope versus decorator scope
In a decorator factory there are two places to put state. A variable placed in the factory scope is created once per factory call. If that call's result is reused as track = tally(), every function decorated with @track shares the variable. A variable placed in the decorator scope is created once per decorated function. The program below shows the same counter in both places, then a class instance reused across two functions.
from functools import wraps fs = [lambda: i for i in range(3)] print([f() for f in fs]) # one shared cell fs = [lambda i=i: i for i in range(3)] print([f() for f in fs]) # bound at creation def tally(): count = 0 # factory scope def deco(fn): @wraps(fn) def w(*a, **kw): nonlocal count count += 1 w.count = count return fn(*a, **kw) return w return deco track = tally() @track def a(): pass @track def b(): pass a(); a(); b() print(a.count, b.count) def tally_fixed(fn): count = 0 # decorator scope @wraps(fn) def w(*a, **kw): nonlocal count count += 1 w.count = count return fn(*a, **kw) return w @tally_fixed def c(): pass @tally_fixed def d(): pass c(); c(); d() print(c.count, d.count) class CallLimit: def __init__(self, max_calls): self.max_calls = max_calls self.calls = 0 def __call__(self, fn): @wraps(fn) def w(*a, **kw): if self.calls >= self.max_calls: raise RuntimeError(f"{fn.__name__}: limit reached") self.calls += 1 return fn(*a, **kw) return w limit = CallLimit(2) @limit def load(): return "load" @limit def save(): return "save" load(); load() try: save() except RuntimeError as e: print(e) @CallLimit(2) def export(): return "export" print(export())
[2, 2, 2] [0, 1, 2] 2 3 2 1 save: limit reached export
In the shared version, b reports a count of 3 even though it ran once, because a and b increment the same cell. The fixed version gives each function its own count. The class case is the same bug in another form. The instance limit holds one calls attribute, and load used up the whole budget before save ever ran.
| Where the state is created | Who shares it | Typical cause |
|---|---|---|
Factory scope, factory result reused (track = tally()) | Every function decorated with track | Variable defined above def deco |
Decorator scope (def tally_fixed(fn): count = 0) | Only that one function | Variable defined inside the decorator |
One decorator instance used as @limit twice | Every function it decorates | Counter stored on self |
A new instance for each use (@CallLimit(2)) | Only that one function | One instance per decoration |
Ask for every piece of state whether it is per call, per function or per decorator. If it is per function, create it in the scope that runs once per decorated function. For a class, create a new instance for each use or key the state on the function.
Caches, Exceptions and Async
lru_cache on methods
lru_cache builds its key from every argument, and for a method that includes self. The cache holds a strong reference to that key, and the cache itself lives on the class, so it outlives every instance. Each instance whose method is called stays alive for as long as its entry stays in the cache. With maxsize=None that is forever, which is a real memory leak. Prefer cached_property for a value computed once per instance. It stores the result in the instance's own __dict__, so the value disappears with the instance.
lru_cache and unhashable arguments
Cache keys must be hashable, so passing a list, dict or set raises TypeError: unhashable type: 'list'. Either convert the argument to a tuple or frozenset before the call, or do not cache that function.
import gc, weakref from functools import lru_cache, cached_property class Leaky: @lru_cache(maxsize=None) def total(self): return 42 class Fine: @cached_property def total(self): return 42 for cls in (Leaky, Fine): obj = cls() obj.total if cls is Fine else obj.total() ref = weakref.ref(obj) del obj gc.collect() print(cls.__name__, "freed:", ref() is None) @lru_cache def add_all(xs): return sum(xs) try: add_all([1, 2, 3]) except TypeError as e: print(e) print(add_all((1, 2, 3)))
Leaky freed: False Fine freed: True unhashable type: 'list' 6
The leak is silent. Nothing fails, and memory just grows with every instance that calls the method. Use cached_property, or build a cache inside __init__ so it dies with the instance.
Losing exception context
Decorators often catch a low-level error and raise a friendlier one. A bare raise RuntimeError(...) inside the except block only records the old error implicitly, as __context__. The traceback then says "During handling of the above exception, another exception occurred", which reads like a bug in your handler. Writing raise ... from e sets __cause__ explicitly. The traceback then says the first error was the direct cause, and tools can follow that link. The program below compares the two.
from functools import wraps def translate(fn): @wraps(fn) def w(*a, **kw): try: return fn(*a, **kw) except ValueError: raise RuntimeError("bad config") return w def translate_from(fn): @wraps(fn) def w(*a, **kw): try: return fn(*a, **kw) except ValueError as e: raise RuntimeError("bad config") from e return w def parse(): return int("x") for deco in (translate, translate_from): try: deco(parse)() except RuntimeError as err: print(deco.__name__, "cause:", repr(err.__cause__), "| context:", repr(err.__context__))
translate cause: None | context: ValueError("invalid literal for int() with base 10: 'x'") translate_from cause: ValueError("invalid literal for int() with base 10: 'x'") | context: ValueError("invalid literal for int() with base 10: 'x'")
Swallowing the error is worse. If the handler never re-raises, or raises with from None, the original cause is gone for good. Use from None only when the original error would be noise to the reader.
A sync wrapper around async def
Calling an async def function does not run its body. It returns a coroutine object. A plain def wrapper returns that object straight back to the caller. Any try, finally or timing code in the wrapper runs before the real work starts, so it cannot catch errors or measure duration. If nobody awaits the coroutine you also get RuntimeWarning: coroutine '...' was never awaited. The fix is an async def wrapper that does await fn(...).
import asyncio from functools import wraps def swallow_sync(fn): @wraps(fn) def w(*a, **kw): try: return fn(*a, **kw) except ZeroDivisionError: return "caught" return w def swallow_async(fn): @wraps(fn) async def w(*a, **kw): try: return await fn(*a, **kw) except ZeroDivisionError: return "caught" return w async def div(): return 1 / 0 coro = swallow_sync(div)() print(type(coro).__name__) try: asyncio.run(coro) except ZeroDivisionError: print("escaped the handler") print(asyncio.run(swallow_async(div)()))
coroutine escaped the handler caught
| Wrapped thing | Wrapper must be | Wrapper body |
|---|---|---|
def function | def | return fn(*a, **kw) |
async def function | async def | return await fn(*a, **kw) |
| Generator | def | yield from fn(*a, **kw) |
A single def wrapper cannot serve both kinds. Either write two wrappers and choose with inspect.iscoroutinefunction(fn), or keep separate decorators for sync and async code.
The Debug Toolkit
When a decorated function misbehaves, look at what is really there before changing any code. Every layer added by @wraps leaves a trail, and the closure keeps its captured values in plain sight. Five tools cover almost every case.
| Tool | What it tells you |
|---|---|
f.__wrapped__ | The function one layer down. Present only if @wraps or update_wrapper was used. |
inspect.unwrap(f) | Follows __wrapped__ all the way down to the undecorated function. |
f.__code__.co_freevars | Names this function borrows from an enclosing scope. |
f.__closure__[i].cell_contents | The current value of the i-th captured name. |
inspect.signature(f) | The real signature, followed through __wrapped__. With follow_wrapped=False you get the wrapper's own (*a, **kw). |
The program stacks two decorators on one function and then uses each tool. Notice that the cell in the outer wrapper holds the inner wrapper, which is exactly area.__wrapped__. The signature call reports the real parameters even though the code of both wrappers only accepts *a, **kw.
import inspect from functools import wraps def outer(fn): @wraps(fn) def wrapper(*a, **kw): return fn(*a, **kw) return wrapper def inner(fn): @wraps(fn) def wrapper(*a, **kw): return fn(*a, **kw) return wrapper @outer @inner def area(w, h=2): return w * h raw = inspect.unwrap(area) print(area.__wrapped__.__wrapped__ is raw) print(area.__code__.co_freevars) print(area.__closure__[0].cell_contents is area.__wrapped__) print(inspect.signature(area)) print(inspect.signature(area, follow_wrapped=False)) print(raw(3))
True ('fn',) True (w, h=2) (*a, **kw) 6
A quick triage path
Most of the bugs in this section show up as one of three symptoms. Work through them in this order, and use the toolkit above for anything that is left.
| Symptom | Likely cause | First check |
|---|---|---|
TypeError: deco() missing 1 required positional argument | @deco and @deco() mixed up | f.__name__ right after import |
TypeError: unhashable type: 'list' | List, dict or set passed to lru_cache | Convert to tuple or frozenset |
RuntimeWarning: coroutine was never awaited | Sync wrapper on an async def | inspect.iscoroutinefunction(fn) |
| Traceback shows only the wrapper's error | Re-raise without from e | err.__cause__ and err.__context__ |
| Closures all return the same value | Late binding on a loop variable | f.__closure__[0].cell_contents |
| Counts or limits shared between functions | State in factory scope or on a reused instance | f.__code__.co_freevars |
Every mistake here comes from the same question: where does this name live, and who else can see it? When a decorated function acts strangely, check __wrapped__, co_freevars and the cell contents before you change anything.
Part 14 · Summary & Quick Reference
Two Definitions and Two Templates
Everything in this chapter reduces to two definitions and two shapes of code. A closure is an inner function plus the cells it captured from the enclosing call; those cells stay alive after the outer function returns. The capture is by reference, not by value: the closure holds a link to the variable, so it sees whatever the variable holds when you call it, not what it held when the function was defined.
def make_adder(n): def add(x): return x + n return add add5 = make_adder(5) print(add5(3)) print(add5.__code__.co_freevars) print(add5.__closure__[0].cell_contents) fs = [lambda: i for i in range(3)] print([f() for f in fs]) fs = [lambda i=i: i for i in range(3)] print([f() for f in fs])
n outlives make_adder; the three lambdas share one cell until a default argument gives each its own
8 ('n',) 5 [2, 2, 2] [0, 1, 2]
A decorator is any callable that takes a function and returns a replacement for it. The @ line is nothing more than sugar: @d above def f is exactly f = d(f), run right after the def finishes. Because the module-level name now points at whatever the decorator returned, the original function survives only inside the wrapper's closure.
| Closure | Decorator | |
|---|---|---|
| What it is | Inner function plus captured cells | Callable: function in, replacement out |
| Lives on because | The returned function keeps the cells | The name is rebound to the wrapper |
| Key fact | Captures variables, not values | @d is exactly f = d(f) |
The template to memorize
Nearly every decorator you write is this shape: an outer function receiving fn, an inner wrapper that does its extra work and forwards the call, and a return wrapper at the end. The decorator body runs once, at definition time; the wrapper body runs on every call.
from functools import wraps def shout(fn): @wraps(fn) def wrapper(*a, **kw): return fn(*a, **kw).upper() return wrapper @shout def greet(name): 'Say hi.' return 'hi ' + name def bye(name): return 'bye ' + name bye = shout(bye) print(greet('ann'), bye('bob')) print(greet.__name__, greet.__doc__)
the @ form and the explicit rebinding behave identically
HI ANN BYE BOB greet Say hi.
When the decorator needs settings
If the decorator takes arguments, add one more level on the outside. @tag('top') means f = tag('top')(f): the factory is called first and must return a real decorator, and the closure carries the configuration down into every wrapper call. Every level must return the level below it.
- 1factory(config)runs once at the @ line
- 2decorator(fn)runs once, receives the function
- 3wrapper(*a, **kw)runs on every call, sees config and fn
Rules, Stacking Order and Introspection
Three habits make a wrapper behave like the function it replaces. They are cheap to follow every time and expensive to debug when skipped, so treat them as non-negotiable rather than judgement calls.
- Always
@functools.wraps(fn), so the name, docstring and__wrapped__survive. - Always forward
*args, **kwargs, so callers can pass whatever the original accepted. - Always
returnthe call's result, or every decorated call quietly yieldsNone.
A missing @wraps makes docs, logs and pytest ids all say wrapper. A missing inner return makes every call give None. A missing return wrapper makes the decorated name itself None. A bare @repeat where @repeat(3) was needed fails later, far from the decorator line.
Stacking: applied bottom-up, run top-down
With several decorators, the one closest to def wraps first and so becomes the innermost layer. The one at the top wraps last, becomes the outermost layer, and therefore runs first when you call the function. Application side effects, like registration, still fire at import in bottom-up order.
def tag(name): def deco(fn): @wraps(fn) def wrapper(*a, **kw): print('enter', name) return fn(*a, **kw) return wrapper return deco @tag('top') @tag('bottom') def f(): print('body') f()
a factory with three levels, stacked twice
enter top enter bottom body
Introspection cheat sheet
When a decorated function misbehaves, look at what it actually is. These attributes and helpers answer most questions, starting with the first three.
| Tool | Tells you |
|---|---|
f.__name__, f.__doc__ | Identity copied by wraps; wrapper or None means wraps is missing |
f.__wrapped__ | The function one layer down |
inspect.unwrap(f) | The raw function, after walking every layer |
f.__closure__ | Tuple of cells; read one with .cell_contents |
f.__code__.co_freevars | Names this function borrows from an enclosing scope |
inspect.signature(f) | The wrapped function's signature, following __wrapped__ |
import inspect print(greet.__wrapped__.__name__) print(inspect.signature(greet)) print(inspect.unwrap(greet) is greet.__wrapped__) print(greet.__code__.co_freevars) print(greet.__closure__[0].cell_contents is greet.__wrapped__)
greet is the shout-decorated function from earlier
greet (name) True ('fn',) True
Stdlib, Class-Based Decorators, Typing and the Final Rule
A small part of the standard library does most of the decorator work in real code. Know these names well enough to reach for them without looking anything up.
| Name | Use |
|---|---|
wraps / update_wrapper | Keep a function's identity; use update_wrapper for class instances |
partial | Pre-bind arguments with no new behaviour |
cache / lru_cache | Memoise; arguments must be hashable, cache_info() and cache_clear() exist |
cached_property | Compute a per-instance value once and store it in the instance __dict__ |
singledispatch | Choose an implementation by the first argument's type |
total_ordering | Fill in the comparison methods from __eq__ and one ordering |
contextmanager | Turn a generator into a with block |
It keys on self, so the cache pins every instance forever. Use cached_property or a per-instance cache for methods.
When a class is the better decorator
A closure is enough for one behaviour. Reach for a class-based decorator when you need named per-function state, such as a call counter you can read later as f.count. Instances are not functions, so use update_wrapper rather than @wraps. If the class will decorate methods, add __get__; otherwise the instance is skipped during attribute lookup and no self is passed.
from functools import update_wrapper, partial class Counted: def __init__(self, fn): self.fn = fn self.count = 0 update_wrapper(self, fn) def __call__(self, *a, **kw): self.count += 1 return self.fn(*a, **kw) def __get__(self, obj, objtype=None): if obj is None: return self return partial(self.__call__, obj) class Greeter: @Counted def hi(self, name): return 'hi ' + name g = Greeter() print(g.hi('ann'), Greeter.hi.count)
__get__ hands the instance to the call, so self arrives
hi ann 1Keeping signatures in typed code
A bare *args, **kwargs wrapper erases the signature for type checkers. In a typed codebase, use ParamSpec so the decorated function keeps its parameters and return type.
import inspect from functools import wraps from typing import Callable, ParamSpec, TypeVar P = ParamSpec('P') R = TypeVar('R') def logged(fn: Callable[P, R]) -> Callable[P, R]: @wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) return wrapper @logged def add(x: int, y: int = 1) -> int: return x + y print(add(2), inspect.signature(add))
3 (x: int, y: int = 1) -> int
Decorators are for cross-cutting concerns: logging, caching, retry, auth, timing and registration. Business logic stays in the function.
Part 15 · Check yourself
Quiz
These questions ask you to predict what Python does or to find the bug, so work each one out before you open the answer.
What does this print, and why?
- It prints
[2, 2, 2]and then[0, 1, 2]. - The three lambdas in
fsshare one cell fori. The cell is read when each lambda is called, and by then the loop has finished withi == 2. - A default argument is evaluated when the lambda is created, so each lambda in
gsstores its own value ofi. A factory function that returns the lambda would fix it in the same way.
fs = [lambda: i for i in range(3)] print([f() for f in fs]) gs = [lambda i=i: i for i in range(3)] print([g() for g in gs])
This decorator is applied to ping. What happens when you call ping(), and where does the mistake sit?
- The call raises
TypeError: repeat... deco() missing 1 required positional argument. Decoration itself succeeds without any error. @repeatwithout parentheses meansping = repeat(ping). That passes the function in asnand returnsdeco, sopingis now bound todeco.- The cause is the decorator line, not the call. The fix is
@repeat(3). A guard such asif callable(n): raise TypeError(...)inside the factory would make the error appear at the decorator line.
def repeat(n): def deco(fn): def w(*a, **kw): for _ in range(n): fn(*a, **kw) return w return deco @repeat def ping(): print('pong') ping()
Two decorators are stacked. In what order do the lines print when f() is called, and in what order do the decorators get applied?
- The output is
apply b,apply a,enter a,enter b,body. - Application is bottom-up:
f = tag('a')(tag('b')(f)), sobwraps first and itsapplyline prints first, at definition time. - Execution is top-down: the outermost wrapper (
a) runs first and delegates inward until the realfruns.
def tag(name): def deco(fn): print('apply', name) def w(*a, **kw): print('enter', name) return fn(*a, **kw) return w return deco @tag('a') @tag('b') def f(): print('body') f()
A teammate wrote this counter. Why does it fail, and how do you fix it?
- It raises
UnboundLocalError. The assignmentn += 1makesnlocal toincfor the whole function body, so the read finds no value. - Scope is decided at compile time from the syntax, not from which values exist at runtime.
- Add
nonlocal nas the first line ofincto rebind the enclosingn. Ifnwere a list,n.append(1)would work without any keyword because it mutates and does not rebind.
def make_counter(): n = 0 def inc(): n += 1 return n return inc c = make_counter() c()
You decorate a method with @lru_cache(maxsize=None) and your service's memory climbs steadily. What is happening, and what are two better options?
- The cache key includes
self, so the cache holds a strong reference to every instance it has seen. Those instances can never be garbage collected. - Use
functools.cached_propertyfor a value computed once per instance, or keep a per-instance cache so the cached data dies with the object. - Also check that the arguments are hashable, because a list argument would raise
TypeError: unhashable type: 'list'.
Summary
- A closure is an inner function plus the cells it captured. It outlives the outer call and captures variables by reference, not by value.
@dabovedef fis exactlyf = d(f). A decorator is any callable that takes a function and returns a replacement.- The template to remember is
@wraps(fn)on the wrapper,*args, **kwargsforwarded, and the call's result returned. - A decorator with arguments has three levels (factory, decorator, wrapper), and it needs parentheses:
@repeat(3), never bare@repeat. - Stacked decorators apply bottom-up but run top-down, so order changes behaviour for caching, logging, retry and timing.
nonlocalrebinds an enclosing name, mutation needs no keyword, and a loop variable captured by a closure shows its final value.- Use decorators for cross-cutting concerns like caching, retry, auth, timing and registration, and keep business logic in the function.