Python Decorators Explained: Wraps, Arguments and Stacking
A Python decorator takes a function and returns a replacement: @deco above def f means f = deco(f). How functools.wraps, arguments and stacking work.
- Course: Python study plan
- Module: Decorators, descriptors and the data model
- Kind: Lesson
- Reading time: 15 min
- Runtime: CPython 3.11
What is a decorator in Python?
A decorator in Python is a function that takes a function and returns a replacement for it, and @deco above a def is shorthand for f = deco(f). The decorator runs once, when the function is defined; the wrapper it returns runs on every call, usually accepting *args, **kwargs, calling the original and returning its result. functools.wraps copies the original's name and docstring onto the wrapper.
Lesson
A decorator is a function that takes a function and returns a function, and the @name line above a def is only syntax for f = name(f). Everything else follows: a decorator can add behaviour before or after every call (logging, timing, retrying, caching), replace the function entirely, register it somewhere, or check its arguments — without touching the body. Module 4's closures and Module 11's functools.wraps were the preparation. This lesson builds decorators from the plain form up: the wrapper shape, @wraps, decorators with arguments (three levels of def), stacking order, class decorators, and the standard ones you already use.
The desugaring
def shout(fn):
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello {name}"
# is exactly:
def greet(name):
return f"hello {name}"
greet = shout(greet)
greet("ada") # 'HELLO ADA'
shout receives the original function, defines wrapper — a closure over fn — and returns it; the name greet is rebound to wrapper. *args, **kwargs let the wrapper accept whatever the original does and pass it through. The decorator runs once, at definition time; the wrapper runs on every call.
@wraps
from functools import wraps
def shout(fn):
@wraps(fn) # copies __name__, __doc__, __qualname__, __module__, sets __wrapped__
def wrapper(*args, **kwargs):
return fn(*args, **kwargs).upper()
return wrapper
greet.__name__ # 'greet', not 'wrapper'
greet.__wrapped__ # the original, for tests and introspection
Without @wraps, every decorated function reports its name as wrapper in tracebacks, help() and test output, and inspect.signature shows (*args, **kwargs). It is one line and it is not optional.
The common wrappers
def logged(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"calling {fn.__name__}{args}")
result = fn(*args, **kwargs)
print(f"{fn.__name__} returned {result!r}")
return result
return wrapper
def count_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
wrapper.calls += 1 # state on the wrapper function object
return fn(*args, **kwargs)
wrapper.calls = 0
return wrapper
def memoize(fn):
cache = {} # state in the closure
@wraps(fn)
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
Before/after behaviour, a counter, a cache: the state lives either in the closure (cache) or as an attribute on the wrapper (wrapper.calls), and the wrapper always returns what the original returned unless transforming the result is the point.
Decorators with arguments
@retry(3) is a call that returns a decorator, so the function has three levels:
def retry(times): # 1. takes the arguments
def decorator(fn): # 2. takes the function
@wraps(fn)
def wrapper(*args, **kwargs): # 3. runs on each call
last = None
for _ in range(times):
try:
return fn(*args, **kwargs)
except TransientError as e:
last = e
raise last
return wrapper
return decorator
@retry(3)
def fetch(): ...
# fetch = retry(3)(fetch)
The outer function is a decorator factory. A decorator that works both bare (@retry) and with arguments (@retry(3)) is possible but confusing; pick one form.
Stacking
@logged
@memoize
def slow(n): ...
# slow = logged(memoize(slow))
Decorators apply bottom-up: the one nearest the def wraps first, the top one wraps last and runs outermost on a call. Here logged sees every call, including the ones memoize answers from cache; swapped, only cache misses would be logged. Order matters whenever wrappers have side effects.
Decorating methods and classes
A decorator on a method receives the function before it is bound; self arrives as args[0] in the wrapper, so the generic *args, **kwargs shape works unchanged. A class decorator takes the class and returns it (or a replacement), the shape of @dataclass, @total_ordering and @runtime_checkable:
def register(cls):
REGISTRY[cls.__name__] = cls
return cls
@register
class CsvExporter: ...
def add_repr(cls):
def __repr__(self):
fields = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{type(self).__name__}({fields})"
cls.__repr__ = __repr__
return cls
Registering plugins, adding methods, validating a class's attributes — all by decorating the class at definition time.
A decorator as a class
Anything callable can decorate, so a class with __init__(self, fn) and __call__(self, *args, **kwargs) is a decorator whose state is ordinary attributes rather than closure cells:
import functools
class CountCalls:
def __init__(self, fn):
functools.update_wrapper(self, fn) # the class-form of @wraps
self.fn = fn
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
@CountCalls
def ping(): ...
ping(); ping.calls # 1
The class form reads better when the wrapper has several pieces of state or methods of its own (reset, stats); the function form is shorter for one behaviour. One caveat: a class-based decorator on a method is not a descriptor unless it defines __get__, so self is not bound — the function form (a closure) has that for free.
The ones you already use
@property, @staticmethod, @classmethod (descriptors, lesson 3); @functools.cache, @lru_cache, @wraps, @singledispatch, @total_ordering; @dataclass; @contextmanager; @abstractmethod; @runtime_checkable; @unittest.mock.patch; pytest.fixture; Flask's and FastAPI's route decorators. Every one is name(thing) returning a replacement — nothing more.
Pitfalls
- No
@wraps— tracebacks andhelpshowwrapper. - A wrapper that forgets to
returnthe result. - A wrapper with a fixed signature (
def wrapper(x)) that breaks on keyword arguments. - Confusing the factory (
retry(3)) with the decorator (retry). - Stacking in the wrong order.
- Doing expensive work at decoration time by accident (it runs at import).
Key takeaways
@decoabovedef fisf = deco(f); the decorator runs once, the wrapper on every call.- A wrapper takes
*args, **kwargs, calls the original, returns its result, and carries@wraps(fn). - State lives in the closure or on the wrapper object;
@retry(3)is a factory returning a decorator. - Stacked decorators apply bottom-up and run outermost-first; class decorators take and return a class.
- Every standard decorator is the same mechanism.
Common questions
How do you write a Python decorator that takes arguments?
Add a third level of function. @retry(3) first calls retry(3), a decorator factory that returns the real decorator; that decorator takes the function and returns the wrapper. So @retry(3) above def fetch means fetch = retry(3)(fetch).
Why use functools.wraps in a decorator?
Without it the wrapper hides the original: tracebacks, help() and test output show the name wrapper, and inspect.signature shows (*args, **kwargs). @wraps(fn) copies __name__, __doc__, __qualname__ and __module__ onto the wrapper and sets __wrapped__ to the original.
In what order are stacked decorators applied?
Bottom-up. The decorator nearest the def wraps the function first and the top one wraps last, so the top one runs outermost on each call: @logged over @memoize means logged(memoize(f)), and logged sees even the calls answered from the cache.
What is a class decorator in Python?
A function that takes a class and returns it, or a replacement, at definition time. It is the shape of @dataclass and @total_ordering, and it is used to register plugins, add methods such as __repr__, or validate a class's attributes.
Can a class be used as a decorator in Python?
Yes, because anything callable can decorate. A class whose __init__ takes the function and whose __call__ runs it keeps its state in ordinary attributes, with functools.update_wrapper(self, fn) as its @wraps. On a method it does not bind self unless it also defines __get__.
Exercises
Traced and counted
Write two decorators with functools.wraps: traced prints -> <name>(<args comma-separated>) before the call and <- <name> = <result> after; counted keeps wrapper.calls on the wrapper. Apply both to add(a, b) and square(x) as @counted over @traced. Run lines add 2 3 / square 4, then print calls add=<n> square=<n> and names <add.__name__> <square.__name__>.
Input: call lines. Output: the trace lines, then the two summary lines.
add 2 3
square 4
prints
-> add(2, 3)
<- add = 5
-> square(4)
<- square = 16
calls add=1 square=1
names add squareA retry factory
Write retry(times) — a decorator factory — whose wrapper calls the function up to times times, printing attempt <n> failed: <message> for each TransientError, returning the first successful result, and re-raising the last error when all attempts fail. Decorate fetch, which fails the first k times (read from input) then returns "data". Read k times and print result <value> or gave up, then fetch.__name__.
Input: k times. Output: the attempt lines, the outcome, then the name.
2 3
prints
attempt 1 failed: flaky
attempt 2 failed: flaky
result data
name fetchIn this module: Decorators, descriptors and the data model
- Decorators — functions that wrap functions (this lesson)
- Closures and late binding — cells, factories and stateful callables
- Descriptors — how properties, methods and validated attributes work
- Attribute access — __getattr__, __getattribute__, __setattr__, __dict__ and __slots__
- Classes as objects — type, __new__, __init_subclass__ and metaclasses in outline
- The data model — the rest of the dunders, and a Vector that uses them
- Checkpoint — Decorators, descriptors and the data model
← Checkpoint — Type hints and code quality · Closures and late binding — cells, factories and stateful callables →