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.

Before you start

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.

Assign

f = greet

the name is a label

Store

[greet, bye]

{'csv': parse_csv}

Pass

sorted(xs, key=len)

function goes in

Return

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.

python
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

output
hi
True
greet

Functions 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.

python
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'))
output
loud HELLO!
soft hello...
HEY!
hey...
Common mistake: f = greet() instead of f = greet

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.

python
def greet():
    return 'hi'

f = greet()
print(f)
try:
    f()
except TypeError as e:
    print(e)
output
hi
'str' object is not callable

What 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.

AttributeHolds
__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.

python
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__)
output
area
Rectangle area.
__main__
(2,)
{}
inner
outer.<locals>.inner

That 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.

python
def greet():
    return 'hi'

greet.calls = 0
for _ in range(3):
    greet()
    greet.calls += 1

print(greet.calls)
print(greet.__dict__)
output
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>'.

python
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))
output
True
True
<lambda>
8 12
deflambda
Typetypes.FunctionTypetypes.FunctionType
Bodyany number of statementsone expression
Namethe name you chose'<lambda>'
Docstringallowednot 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.

python
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

output
[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.

python
def twice(fn):
    def run(x):
        return fn(fn(x))
    return run

add3 = lambda x: x + 3
add6 = twice(add3)
print(add6(10))
output
16

Being 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.

python
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

output
True True False
False
10
[2, 4]
Can I put parentheses after it?
Why this matters for decorators

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.

python
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

output
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.

ScopeWhat lives thereExample
LocalParameters and names assigned inside the current functionz = 'local' inside inner
EnclosingNames in an outer function, never the moduley defined in outer, read by inner
GlobalNames assigned at the top level of the modulex defined at module level
Built-inNames Python provides everywherelen, 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.

python
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

output
local enclosing global 5

Assignment 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.

UnboundLocalError from a harmless-looking line

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.

python
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

output
()
('x',)
UnboundLocalError

reads 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.

Scope is a compile-time fact

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.

KeywordRebindsFails when
global xThe module-level name, even from a deeply nested defNever: it creates the name at module level if missing
nonlocal xThe nearest enclosing function's nameNo enclosing function has that name
Do I need a keyword?

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.

python
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

output
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.

python
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

output
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.

StatementNeeds nonlocal or global?Why
lst.append(1)NoMutation: the name is only read
d['k'] = 1NoItem assignment mutates the dict
lst = [1]YesRebinding: creates a local name otherwise
n += 1YesOn an int it rebinds n
python
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

output
[1, 1]
The silent rebind

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).

Rule of thumb

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.

ConditionWhat it meansIf it is missing
Nested functionAn inner def (or lambda) sits inside an outer functionThere is no enclosing function scope to capture from
References an outer nameThe inner body reads a variable that belongs to the outer functionNothing is captured, so the inner function is just an ordinary function
Outer returns the inner oneThe inner function escapes the outer call as a return valueThe 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.

python
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

output
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.

Lifetime of a captured name
  1. 1make_adder(5) startsn is created as a cell
  2. 2add is definedit holds a reference to that cell
  3. 3make_adder returns addthe call frame is gone
  4. 4The cell survivesadd keeps it reachable
  5. 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.

python
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

output
cell
5
9
('n',)
('n',)
False
AttributeLives onTells you
__closure__the inner functionthe actual cell objects, in the same order as co_freevars
__closure__[0].cell_contentsa cellthe value the name currently holds
__code__.co_freevarsthe inner functionnames it borrows from an enclosing scope
__code__.co_cellvarsthe outer functionlocal names that inner functions capture
Debugging habit

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.

python
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])
output
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.

python
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])
output
[2, 2, 2]
[0, 1, 2]
[0, 1, 2]
ApproachWhy it worksWatch out for
lambda i=i: iThe default value is evaluated immediately, so each lambda stores its own copyCallers can accidentally override it by passing an argument
Factory functionEvery call to the factory creates a brand new cellOne extra small function to write
Common mistake

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.

python
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

output
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.

python
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)
output
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.

NeedReach forWhy
One behaviour, small hidden stateClosureShort, no boilerplate, state is private by construction
Several methods sharing stateClassMethods are named and organised in one place
Readable, inspectable stateClassobj.total beats digging through __closure__
A function to hand to decorators or callbacksClosureIt is already just a function
Remember

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.

What happens at @deco / def f
  1. 1def f runsa function object is built
  2. 2deco(f) is calledthe object is passed in
  3. 3the result is returnedusually a wrapper
  4. 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.

python
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

output
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.

python
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

output
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.

CodeRuns whenHow often
Body of decoRight after the def executes (import time)Once per decorated function
Body of wrapperEach time you call the decorated nameEvery call
Body of the original fnOnly if the wrapper calls itZero 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.

python
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

output
None
Silent None

Forgetting 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.

TargetWhat the decorator receivesWhat gets rebound
Module-level functionThe function objectThe module attribute
Nested functionA fresh function object, created on each outer callThe local name inside the outer function
Method inside a classA plain function, before it becomes boundThe class attribute
ClassThe class objectThe 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.

python
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

output
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.

python
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

output
['parse_csv', 'parse_pipe']
True
Placement, not power

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.

python
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

output
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.

Stale references miss the patch

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.

python
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.

output
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 habitWhat it readsWithout wraps
help(f) and pydoc__name__, __doc__, signatureDocuments wrapper with no description
Sphinx autodoc__name__, __doc__, __wrapped__Every decorated function is listed as wrapper, with no docs
pytest__name__ of the collected objectTest ids all read wrapper, and tests can collide
Logging and repr__qualname__, __module__Log lines and reprs name naive.<locals>.wrapper
Registries keyed by namefn.__name__Many routes or handlers land on the same key wrapper
Common mistake: the silent identity loss

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.

What update_wrapper(wrapper, fn) does
  1. 1Copy attributesfor each name in WRAPPER_ASSIGNMENTS: setattr(wrapper, name, getattr(fn, name))
  2. 2Merge dictionariesfor each name in WRAPPER_UPDATES: wrapper.dict.update(fn.dict)
  3. 3Link backwrapper.wrapped = fn
  4. 4Return the wrapperthe same object, now carrying fn's identity
AttributeHow wraps treats itResult 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 replacedthe wrapper's own attributes plus those of fn
__wrapped__set to fna 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.

python
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.

output
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.

python
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__)
output
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).

python
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.

output
(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.

python
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)
output
add
False
True
True
Debugging and testing with unwrap

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.

QuestionAnswered byFixed 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 wrapperNo, still *args, **kwargs
What name does a traceback frame show?__code__.co_nameNo, 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.

python
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.

output
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.

Common mistake: wraps on the wrong function

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.

The rule of thumb

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 tuplepacks keywords into a dict
In fn(...)spreads the tuple into positionalsspreads 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.

python
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))
output
args: ('db',)
kwargs: {'ssl': True}
db:5432 ssl=True
Relay everything, return the result

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.

Never index args[0] blindly

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.

python
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

output
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).

python
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

output
HELLO, ADA!
HELLO, BO?
GoalWhere it happensTechnique
Read a parameter by namebefore the callbind, apply_defaults, then arguments[name]
Change an argumentbefore the callrebuild args or edit the kwargs dict
Change the return valueafter the callstore 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 functionWrapper must beForwarding line
plain defplain defreturn fn(*args, **kwargs)
async defasync defreturn await fn(*args, **kwargs)
generatorgenerator defreturn (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.

python
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)))
output
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.

python
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)
output
81
1024
power {'exp': 2}
Wrapper or partial?

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.

python
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)
output
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.

A narrower wrapper breaks every caller

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.

Argument forwarding checklist

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.

What @repeat(3) does, in order
  1. 1repeat(3)the factory runs and receives the settings
  2. 2returns decoa plain decorator that remembers n
  3. 3deco(f)the decorator receives your function
  4. 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.

LevelNameReceivesReturnsRuns
1factory, repeat(n)the settingsthe decoratoronce per @repeat(...) line
2decorator, deco(fn)the functionthe wrapperonce per decorated function
3wrapper, w(*a, **kw)the call argumentsthe function's resultevery 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.

python
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

output
pong
pong
pong
ping
deco
Every level returns

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.

python
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

output
['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.

python
@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

output
deco
TypeError
The error shows up far from its cause

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.

python
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

output
TypeError repeat(n) needs an int, did you forget the call?
ValueError repeat(n) needs n >= 1

Supporting @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.

python
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

output
[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 fnthe functionnothing, so fn is None
What deco returnsthe finished wrapperpartial(deco, x=2)
Who then sees the functiondeco directlythe 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.

python
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

output
['f1', 'g1'] ['f1', 'g1']
['f2'] ['g2']
Mutable state parked in the factory is shared

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.

SpellingWhat the parentheses configureEquivalent to
@lru_cache(maxsize=128)how many results the cache keepsf = lru_cache(maxsize=128)(f)
@app.route('/path')the URL rule that will run ff = app.route('/path')(f)
@pytest.mark.parametrize(...)the argument names and test casesf = parametrize(...)(f)
@retry(times=3, delay=1)attempts and pause between themf = retry(times=3, delay=1)(f)
One rule, two shapes

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.

What @Counted does
  1. 1def greet(name): ...plain function object is created
  2. 2Counted(greet)init stores fn, sets count = 0
  3. 3greet = <Counted instance>the name now points at the instance
  4. 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 wrapperClass-based decorator
Where state livesA cell captured by the wrapperAn attribute on the instance
Updating a counternonlocal count then count += 1self.count += 1
Reading it from outsideDig through __closure__greet.count
Best forOne behaviour, no state to exposeNamed 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.

python
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.

output
hi Ada
hi Bo
2
greet Say hello.
Common mistake: @wraps on an instance

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.

What happens on g.hello(...)

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.

python
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.

output
broken: self was never passed
hello Ada
hello Bo
2

Notice 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.

Or skip the descriptor

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.

Common mistake: forgetting get

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.

python
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.

output
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.

DecoratorWhat it does to the classNeeds from you
@dataclassGenerates __init__, __repr__ and __eq__ from annotated fieldsAnnotated class attributes
@functools.total_orderingFills in the missing comparison methods__eq__ plus one of __lt__, __le__, __gt__, __ge__
@typing.runtime_checkableLets isinstance check a Protocol by method namesA Protocol class
python
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))
output
Item(name='pen', qty=3)
True
True
False
Two different targets

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.

DecoratorFirst argumentHow you use it
@staticmethodnoneC.f() or obj.f()
@classmethodclsC.f() or obj.f()
@propertyselfobj.f, no call parentheses
@functools.cached_propertyselfobj.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.

python
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__.

output
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.

Common mistake: wrong order or wrong class

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.

@a over @b over def f
  1. 1def fthe real function object
  2. 2b(f)applied first, innermost
  3. 3a(b(f))applied last, outermost
  4. 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.

python
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

output
-- 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 happensOnce, as the def statement finishesOn every call of f()
OrderBottom to top: b then aTop to bottom: a then b then real f
Which code runsThe decorator body, deco(fn)The wrapper body
UnwindingNot applicableReverse: b exits before a
Mnemonic

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.

All of this fires at import time

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.

python
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

output
log: square(3,)
9 9
log: cube(3,)
log: cube(3,)
27 27
Stack (top to bottom)What you observeUse it when
@cache over @logOnly cache misses are logged; repeats are silentYou want a log of real work
@log over @cacheEvery call is logged, hits includedYou want a log of traffic
@timer over @retryOne time covering all attempts and any sleepsYou care about total latency for the caller
@retry over @timerOne time per attemptYou 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.

python
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

output
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.

python
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

output
hello
GOODBYE
Common mistake: registering under the decorators

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.

python
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

output
'classmethod' object is not callable
Good
DecoratorPosition in a stackWhy
@app.route(...)OutermostRegistration must see the final function
@staticmethodOutermostYour wrapper expects a plain callable, which sits underneath it
@classmethodOutermostIts object is a descriptor and cannot be called directly
@propertyOutermost, over its getterThe class must see a property object, not a wrapper function
Logging, timing, caching, authUnderneath the ones aboveThey 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.

python
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

output
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.

QuestionToolAnswer you get
How many layers are there?Follow f.__wrapped__ repeatedlyOne 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 layerThe chain stops early at the culprit
Why does the profile look noisy?Count wrapper entriesOne per layer per call
Common mistake: debugging by reading the order backwards

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.

python
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

output
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)
MemoryGrows without limitCapped, least recently used entry is dropped
Best forSmall, finite input spaceLong-running code with many distinct inputs
Argument ruleHashable onlyHashable only
Common mistake: caching a method

@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()
MeaningElapsed-time counterCurrent wall-clock date and time
Can jump backwardsNoYes, on clock changes
Use it forDurationsTimestamps 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.

python
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")
output
call divide(10, b=4)
result 2.5
True
call divide(1, 0)
error divide: ZeroDivisionError: division by zero
caller saw the error
Common mistake: swallowing the error while logging it

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.

One retry loop
  1. 1Call fntry the real function
  2. 2Success?return the result
  3. 3Last attempt?yes: re-raise the error
  4. 4Sleep delay * 2**ithen loop again
python
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)
output
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
Common mistake: retrying every exception

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.

python
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())
output
denied: ana lacks role 'admin'
abab
bad call: times must be int, got str
saved None
Gates run before the function

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.

python
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)
output
['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).

python
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"]))
output
int 3
object 'hi'
list of int 1, object 'a'
Dispatch looks at one argument only

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.

FrameworkDecoratorWhat it does
Flask@app.route('/path')Registers the function as a handler for a URL, like @register
pytest@fixtureRegisters the function as a named setup resource tests can request
Django@login_requiredAuth gate that redirects before the view runs
Click@click.commandTurns 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.

JobThe wrapper doesReady-made?
CachingLooks up arguments, skips the call on a hitfunctools.cache, lru_cache
TimingReads perf_counter before and afterWrite your own
LoggingRecords name, args, result, errorsWrite your own
RetryLoops, sleeps, re-raises on the last tryWrite your own
Auth / validationChecks first, then delegatesFrameworks, or write your own
Rate limitingClosure keeps last call time or tokensWrite your own
RegistrationStores the function, returns it unchangedWrite your own
DispatchChooses by first argument's typefunctools.singledispatch
Cross-cutting, not business logic

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.

python
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

output
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.

@decof = deco(f)
Where it readsRight at the definitionWherever the assignment sits
Works on imported objectsNo, you must own the defYes
Original still reachableOnly through __wrapped__Yes, if you keep another name
Best forYour own functionsThird-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.

python
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

output
('calls', 'fn')
3
2
Closure decoratorClass decorator
SizeTerse, one functionMore lines, one class
StateHidden in cells, reached with nonlocalNamed attributes such as self.calls
Extra methodsNoneEasy: reset(), stats()
InspectionDig through __closure__Plain attribute access
On methodsWorks as isNeeds __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.

DecoratorMixin or subclass
ReachesAny callable, unrelated functionsOnly classes in the hierarchy
GranularityOne function at a timeEvery method of the class
Setup costNone, just add a lineDesign the hierarchy first
Fits whenThe concern is cross-cuttingBehavior 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(...).

python
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

output
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.

MiddlewareDecorator
ScopeWhole pipelineOne handler
DeclaredIn one config placeBeside each handler
Good forRequest IDs, CORS, compressionPer-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.

python
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

output
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.

python
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

output
CacheInfo(hits=2, misses=3, maxsize=2, currsize=2)
False
True
You gainYou pay
O(1) lookup on a hitMemory that grows without bound at maxsize=None
No recomputationKeys keep their arguments alive
Free stats via cache_info()Every argument must be hashable
Common mistake: lru_cache on a method

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.

python
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)

output
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).

Decorator or explicit call?

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.

SituationPreferWhy
Same auth check on 20 handlersDecoratorOne definition, visible on each handler
Retry around one flaky network callExplicit callUsed once, so keep it in view
Timing a block inside a functionwith blockA decorator can only wrap the whole function
Wrapping a library function you importf = deco(f)You cannot put @ on code you do not own
Needs named state and reset methodsClass decoratorState is inspectable
Behavior tied to a family of typesMixin or subclassIt belongs to the type, not to a function
Common mistake: a decorator for a one-off

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.

Where the line sits

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.

python
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)
output
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.

MistakeWhat you seeFix
No @wraps__name__ is 'wrapper', docstring is NonePut @wraps(fn) on every wrapper
No return in the wrapperEvery call gives Nonereturn fn(*a, **kw)
No return wrapperThe decorated name is None; calling it raises TypeError: 'NoneType' object is not callableEnd the decorator with return wrapper
@repeat for a factoryName becomes deco; error appears at the first callWrite @repeat(3)
@deco() for a plain decoratorTypeError: deco() missing 1 required positional argument at importWrite @deco
Common mistake: the parentheses mix-up

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.

Common mistake: assuming the wrapper is fine because nothing crashed

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.

python
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())
output
[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 createdWho shares itTypical cause
Factory scope, factory result reused (track = tally())Every function decorated with trackVariable defined above def deco
Decorator scope (def tally_fixed(fn): count = 0)Only that one functionVariable defined inside the decorator
One decorator instance used as @limit twiceEvery function it decoratesCounter stored on self
A new instance for each use (@CallLimit(2))Only that one functionOne instance per decoration
Common mistake: state that belongs to a function but lives with the decorator

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.

python
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)))
output
Leaky freed: False
Fine freed: True
unhashable type: 'list'
6
Common mistake: caching a method with lru_cache

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.

python
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__))
output
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(...).

python
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)()))
output
coroutine
escaped the handler
caught
Wrapped thingWrapper must beWrapper body
def functiondefreturn fn(*a, **kw)
async def functionasync defreturn await fn(*a, **kw)
Generatordefyield from fn(*a, **kw)
Common mistake: one decorator for both sync and async functions

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.

ToolWhat 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_freevarsNames this function borrows from an enclosing scope.
f.__closure__[i].cell_contentsThe 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.

python
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))
output
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.

Which bug is it?
SymptomLikely causeFirst check
TypeError: deco() missing 1 required positional argument@deco and @deco() mixed upf.__name__ right after import
TypeError: unhashable type: 'list'List, dict or set passed to lru_cacheConvert to tuple or frozenset
RuntimeWarning: coroutine was never awaitedSync wrapper on an async definspect.iscoroutinefunction(fn)
Traceback shows only the wrapper's errorRe-raise without from eerr.__cause__ and err.__context__
Closures all return the same valueLate binding on a loop variablef.__closure__[0].cell_contents
Counts or limits shared between functionsState in factory scope or on a reused instancef.__code__.co_freevars
Takeaway

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.

python
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

output
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.

ClosureDecorator
What it isInner function plus captured cellsCallable: function in, replacement out
Lives on becauseThe returned function keeps the cellsThe name is rebound to the wrapper
Key factCaptures 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.

python
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

output
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.

Argument-taking template: three levels
  1. 1factory(config)runs once at the @ line
  2. 2decorator(fn)runs once, receives the function
  3. 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 return the call's result, or every decorated call quietly yields None.
The silent failures

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.

python
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

output
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.

ToolTells 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_freevarsNames this function borrows from an enclosing scope
inspect.signature(f)The wrapped function's signature, following __wrapped__
python
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

output
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.

NameUse
wraps / update_wrapperKeep a function's identity; use update_wrapper for class instances
partialPre-bind arguments with no new behaviour
cache / lru_cacheMemoise; arguments must be hashable, cache_info() and cache_clear() exist
cached_propertyCompute a per-instance value once and store it in the instance __dict__
singledispatchChoose an implementation by the first argument's type
total_orderingFill in the comparison methods from __eq__ and one ordering
contextmanagerTurn a generator into a with block
lru_cache on a method

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.

Closure or class?
python
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

output
hi ann 1

Keeping 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.

python
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))
output
3 (x: int, y: int = 1) -> int
The final rule

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 fs share one cell for i. The cell is read when each lambda is called, and by then the loop has finished with i == 2.
  • A default argument is evaluated when the lambda is created, so each lambda in gs stores its own value of i. 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.
  • @repeat without parentheses means ping = repeat(ping). That passes the function in as n and returns deco, so ping is now bound to deco.
  • The cause is the decorator line, not the call. The fix is @repeat(3). A guard such as if 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)), so b wraps first and its apply line prints first, at definition time.
  • Execution is top-down: the outermost wrapper (a) runs first and delegates inward until the real f runs.
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 assignment n += 1 makes n local to inc for 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 n as the first line of inc to rebind the enclosing n. If n were 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_property for 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.
  • @d above def f is exactly f = 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, **kwargs forwarded, 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.
  • nonlocal rebinds 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.