A Python decorator is a callable that takes a function and returns a replacement function that wraps the original. The @decorator syntax above a function definition is sugar for func = decorator(func). The wrapper usually runs code before and after the original call, intercepting arguments and return values. Three prerequisites unlock the mental model: functions are first-class objects, closures let an inner function capture variables from the enclosing scope, and *args/**kwargs lets a wrapper accept any signature. With those three, you can build the basic pattern from scratch and then extend it: functools.wraps preserves metadata; decorators that take arguments add another wrapper layer; async functions need an async wrapper; PEP 612's ParamSpec (Python 3.10+) lets you type the wrapper correctly; and every decorator adds a measurable function-call cost. This piece is one of 17 short explainers in our Python Concepts Explained reference.
Key Takeaways
Decorator = function transformer. Takes a function, returns a new function. @decorator is sugar for func = decorator(func).
Always use functools.wraps. It copies __name__, __qualname__, __doc__, __module__, __dict__, __annotations__, and sets __wrapped__. Without it, debuggers, docs, and frameworks (FastAPI, pytest, Flask) break.
Decorators with arguments add one layer.@retry(3) first calls retry(3) to get a decorator, then applies it. Three nested functions: outer takes the decorator arguments, middle takes the function, inner is the wrapper.
Async functions need an async wrapper. A sync wrapper around async def returns a coroutine object instead of awaiting it, breaking the call site.
Typing decorators correctly is a Python 3.10+ thing.typing.ParamSpec from PEP 612 finally lets you preserve the wrapped function's signature for static analysis.
Three views of the same idea: original, manually decorated, and the @ sugar. Every decorator pattern builds on this shape.
Three Prerequisites You Already Have
Decorators look intimidating because they combine three features at once. Each is small on its own; together they're the whole game.
1. Functions are first-class objects
You can assign a function to a variable, store it in a list, pass it to another function, or return it from one.
def greet(name):
return f"Hello, {name}"
# Assign to a variable, call through that
say_hi = greet
print(say_hi("Ada")) # Hello, Ada
# Pass as an argument
def call(fn, name):
return fn(name)
call(greet, "Ada") # Hello, Ada
The multiply function "remembers" the factor from when it was created. This is exactly how a decorator's wrapper remembers the function it wraps.
3. *args and **kwargs
A function that takes *args, **kwargs accepts any combination of positional and keyword arguments. Decorators use this to pass through arguments regardless of what the wrapped function expects (for the full mechanic see our pass-by-reference explainer).
With those three concepts, every decorator pattern below builds from the same shape.
Your First Decorator From Scratch
Build a decorator that prints the function name and the result of each call.
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"{func.__name__}({args}, {kwargs}) = {result}")
return result
return wrapper
def add(x, y):
return x + y
# Manual application
add = trace(add)
add(2, 3)
# add((2, 3), {}) = 5
The trace function takes add and returns wrapper. The wrapper closes over func (the original add) and calls it inside, printing around the call. Reassigning add = trace(add) replaces the global name add with the wrapper. The original function is still alive, captured inside wrapper's closure.
The @ Syntax Sugar
Writing add = trace(add) after the function definition is verbose. Python provides syntactic sugar:
@trace
def add(x, y):
return x + y
add(2, 3)
# add((2, 3), {}) = 5
The @trace line above def add is identical to writing add = trace(add) on the line after the function definition. Nothing more. The @ just moves the decoration above the function, which reads more naturally because you see it before the body. Both forms produce the same bytecode.
functools.wraps: What It Actually Does
Apply trace from above and inspect the result:
@trace
def add(x, y):
"""Add two numbers."""
return x + y
print(add.__name__) # 'wrapper' ← not 'add' anymore
print(add.__doc__) # None ← docstring lost
The wrapper replaced the original at the name add, taking the wrapper's own metadata with it. This breaks every tool that introspects functions: debuggers, IDE tooltips, help(add), pytest's test discovery, FastAPI's dependency injection, Sphinx documentation. The fix is functools.wraps:
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"{func.__name__}({args}, {kwargs}) = {result}")
return result
return wrapper
@trace
def add(x, y):
"""Add two numbers."""
return x + y
print(add.__name__) # 'add'
print(add.__doc__) # 'Add two numbers.'
print(add.__wrapped__) # the original add, still reachable
What functools.wraps copies, per the Python docs: __module__, __name__, __qualname__, __annotations__, __doc__, and __dict__. It also adds __wrapped__ pointing at the original, so frameworks can reach past the wrapper when they need the underlying function. Apply it to every decorator without thinking twice; the cost is one extra line.
Decorators That Take Arguments
So far @trace takes no parameters. What about a @retry that takes the retry count?
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last = e
raise last
return wrapper
return decorator
@retry(3)
def flaky_request():
...
Three nested functions instead of two:
Outerretry(times): takes the decorator arguments, returns a decorator.
Middledecorator(func): takes the function, returns the wrapper.
Innerwrapper(*args, **kwargs): takes the call arguments, runs the logic.
So @retry(3) first calls retry(3) which returns decorator; then decorator receives flaky_request and returns the wrapper that replaces it. Every "decorator with arguments" has this three-layer shape.
The Optional-Argument Pattern
Sometimes you want a decorator that works both ways: @retry (no parentheses, use a default) and @retry(5) (explicit). Without special handling, only one syntax works. The standard idiom branches on the first argument:
import functools
def retry(func=None, *, times=3):
if func is None:
# Called as @retry(times=5)
return lambda f: retry(f, times=times)
# Called as @retry (bare)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
try:
return func(*args, **kwargs)
except Exception:
continue
raise
return wrapper
@retry
def flaky_a():
...
@retry(times=5)
def flaky_b():
...
The trick: the first positional argument is either the function being decorated (bare form) or absent (parenthesized form, with only keyword args). Checking if func is None distinguishes the two. Using keyword-only arguments after * makes the API safe (callers can't pass times positionally and confuse the check). This pattern shows up in well-designed library decorators across the ecosystem.
Stateful Decorators: When a Class Beats a Function
A function-based decorator can hold state through closure variables, but class-based decorators express the same intent more cleanly when the state is non-trivial:
functools.update_wrapper(self, func) is the class equivalent of @functools.wraps. The __call__ method makes the instance callable, so it behaves like a function. The class form wins when you need:
Configurable state across calls (counters, caches, rate limiters)
Multiple methods on the decorator (e.g., a reset method)
Introspection: you can ask the decorator about itself
For purely behavioral decorators (logging, timing, retrying) the function form is usually shorter and equally clear.
Decorating Async Functions
Modern Python code increasingly uses async def. A naive synchronous wrapper around an async function returns a coroutine object instead of awaiting it, which silently breaks the call site:
# WRONG: sync wrapper around async
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) # returns a coroutine, not the result
return wrapper
The fix is to make the wrapper async and await the inner call:
For a decorator that should support both sync and async targets, check asyncio.iscoroutinefunction(func) and return the appropriate wrapper:
import asyncio
import functools
def trace(func):
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def aw(*args, **kwargs):
result = await func(*args, **kwargs)
print(f"{func.__name__} (async) = {result}")
return result
return aw
@functools.wraps(func)
def sw(*args, **kwargs):
result = func(*args, **kwargs)
print(f"{func.__name__} = {result}")
return result
return sw
Most production libraries (FastAPI, Starlette, anyio) follow exactly this pattern for decorators that need to be agnostic.
Typing Decorators with ParamSpec (PEP 612)
Before Python 3.10, decorators were essentially un-typeable. The wrapper had to declare *args, **kwargs, which static type checkers saw as "any signature accepts anything", losing all the wrapped function's type information. PEP 612 introduced ParamSpec to fix this.
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def trace(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
result = func(*args, **kwargs)
print(f"{func.__name__} = {result}")
return result
return wrapper
@trace
def add(x: int, y: int) -> int:
return x + y
add("a", "b") # type checker error: expected int, got str
P captures the wrapped function's parameter signature; R captures the return type. The wrapper's type signature uses P.args and P.kwargs as type hints. Static checkers (mypy, pyright) then know that the decorated add still takes two ints and returns an int. For library code that publishes decorators, ParamSpec is the modern standard. For application code, the cost-benefit depends on whether your team runs a strict type checker.
Concatenate for decorators that change signatures
If your decorator adds arguments (an injected context, a database session, a user object), the wrapped signature is different from the original. typing.Concatenate expresses exactly this:
from typing import Callable, ParamSpec, TypeVar, Concatenate
P = ParamSpec("P")
R = TypeVar("R")
def with_db(func: Callable[Concatenate[Session, P], R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
with open_session() as session:
return func(session, *args, **kwargs)
return wrapper
@with_db
def get_user(session: Session, user_id: int) -> User:
return session.query(User).get(user_id)
# The decorated callable has signature (user_id: int) -> User
# The 'session' parameter has been removed
get_user(42)
The original function declares session as its first parameter; the decorator injects it; the wrapped version only takes the remaining parameters. Concatenate[Session, P] says "the first param is a Session, followed by whatever P captures". Static checkers then validate calls correctly. This is the pattern dependency-injection frameworks use under the hood.
When NOT to Use a Decorator
Decorators are a tool, not a goal. Three signs the pattern is wrong for your problem:
The transformation needs the call site's context. Decorators see only the wrapped function and its arguments. If you need to know which caller invoked the function, who is logged in, or what request is being handled, a decorator can't access that without global state. Pass the context as an argument or use a context manager instead.
The behavior is one-off. A decorator is reusable infrastructure. If a single function needs special timing, just measure it inline; don't define @timer for a single call. The reusability bar is usually three or more applications.
The decorator changes the function's signature in non-obvious ways. A wrapper that swallows the first argument or that returns a tuple where the original returned a single value is a debugging trap. If you really need to change the signature, use a clearly-named factory function instead of a decorator; the call site will read better.
The cleanest production codebases use a small number of well-named decorators (@cache, @retry, @timer) and reach for explicit code everywhere else. When in doubt, write the explicit version first and refactor into a decorator only when you have three real call sites.
Standard-Library Decorators Worth Knowing
Beyond @property, @classmethod, and @staticmethod, the standard library ships several decorators that solve common problems without you writing your own.
Decorator
Module
What it does
@functools.cache
functools (3.9+)
Unbounded memoization; simpler than lru_cache(maxsize=None)
@functools.lru_cache
functools
Bounded memoization with LRU eviction
@functools.cached_property
functools (3.8+)
Compute a property once per instance, store on the instance
@functools.singledispatch
functools
Build a function with type-based dispatch
@functools.total_ordering
functools
Generate the missing comparison methods from __eq__ and one of __lt__, __le__, __gt__, __ge__
@dataclasses.dataclass
dataclasses
Generate __init__, __repr__, __eq__ from class fields
@contextlib.contextmanager
contextlib
Turn a generator into a context manager
@typing.overload
typing
Declare multiple type signatures for the same function
Each of these is a real decorator built using the patterns from this article. Reading their source (linked from the functools docs) is one of the best ways to internalize how decorators work in production code.
Performance: Every Decorator Costs Something
The wrapper adds at least one Python-level function call per invocation. For trivial functions the relative cost can be substantial:
Call
Time per call
Notes
bare add(2, 3)
~50 ns
baseline
@functools.wraps trace wrapper
~250 ns
~5× overhead
class-based __call__ wrapper
~300 ns
slightly heavier dispatch
@functools.cache hit
~50 ns
cache lookup, no real call
@functools.cache miss
~350 ns
compute + insert
For a function that actually does work (database query, JSON parsing, image processing), the wrapper overhead is invisible. For a trivial getter called millions of times in a tight loop, decorators can be the bottleneck. The two common fixes when this matters: cache the result with @functools.cache (Python 3.9+, simpler than @functools.lru_cache(maxsize=None)), or skip the decorator for hot paths and apply the behavior at the call site instead.
Decorating Classes (Not Just Functions)
The @ syntax also works on class definitions. The decorator receives the class and returns it (usually modified or the same class) instead of a function:
import functools
def register_subclass(cls):
# Track every class decorated with this
register_subclass.registry.append(cls)
return cls
register_subclass.registry = []
@register_subclass
class WidgetA:
pass
@register_subclass
class WidgetB:
pass
print(register_subclass.registry)
# [<class 'WidgetA'>, <class 'WidgetB'>]
This pattern shows up in plugin systems, ORM mappers, and CLI framework registrations. The decorator never wraps the class; it just records it and returns the original. dataclasses.dataclass is the most prominent example in the standard library: it inspects the class, adds methods like __init__ and __repr__, then returns the modified class.
Built-in class decorators
Three built-in decorators are so common they look like syntax: @property turns a method into a getter; @classmethod binds the first argument to the class instead of the instance; @staticmethod drops the bound argument entirely. They are not magic; they're regular decorators provided by the language.
The wrapper carries the stats function as an attribute, so callers can inspect runtime behavior without external state.
2. @memoize via functools.cache
import functools
@functools.cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
For most cases, @functools.cache (Python 3.9+) replaces a hand-written memoization decorator. Use @functools.lru_cache(maxsize=128) when the cache should be bounded.
3. @validate_types using type hints
import functools
import inspect
def validate_types(func):
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
for name, value in bound.arguments.items():
expected = sig.parameters[name].annotation
if expected is not inspect.Parameter.empty:
if not isinstance(value, expected):
raise TypeError(
f"{name}: expected {expected.__name__}, got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
@validate_types
def add(x: int, y: int) -> int:
return x + y
add(2, 3) # 5
add(2, "three") # TypeError: y: expected int, got str
Real production validators (pydantic, attrs) ship type-checking at construction time; the example above is a learning exercise that fits in 15 lines. The pattern combines inspect.signature, functools.wraps, and the call-by-sharing argument forwarding from earlier sections.
Debugging Decorated Functions
Decorators can obscure stack traces and break breakpoints if not built carefully. Three practical tips that pay back the moment a production bug shows up.
Reach the original via __wrapped__
When you applied functools.wraps, the wrapper sets __wrapped__ to the original. You can unwrap explicitly:
@trace
@retry(3)
def fetch(url):
...
original = fetch
while hasattr(original, "__wrapped__"):
original = original.__wrapped__
print(original) # <function fetch at 0x...> ← the bare function
inspect.unwrap(fetch) does the same thing in one call. Useful in tests that want to bypass decorators (e.g. skip the cache or retry layer to verify the underlying logic).
Stack traces show wrapper frames
When the wrapped function raises, the traceback includes the wrapper's frame: wrapper -> func. This is noise, but it's also a hint that decoration is involved. If you see wrapper in every traceback, simplify the decorator or use traceback.extract_tb() filtering in your logging.
Use __qualname__, not __name__
For methods inside classes, __name__ alone is ambiguous: many classes can have a save method. __qualname__ gives the fully-qualified path (User.save, Order.save). functools.wraps copies both; logging the qualified name produces traceable output without ambiguity.
Stacking and Order
Multiple decorators stack from the bottom up:
@a
@b
@c
def f():
...
# Equivalent to:
f = a(b(c(f)))
The decorator closest to the function (@c) is applied first. Order matters when the decorators have side effects: @cache then @timer times the cache hit; @timer then @cache times the function body. The standard guideline: caching and rate limiting go innermost (closest to the function); logging and instrumentation go outermost.
Common Mistakes
Five traps to watch for:Mistake 1: forgetting functools.wraps. The wrapper takes the wrapped function's place at its name; without wraps, debuggers and frameworks see the wrapper's metadata instead. Apply it to every decorator.Mistake 2: synchronous wrapper around async def. The wrapper returns a coroutine object instead of awaiting it. Either make the wrapper async or branch on asyncio.iscoroutinefunction.Mistake 3: missing parentheses on a parameterized decorator.@retry when the decorator expects @retry(3) calls retry(your_function) instead, returning something that may or may not be callable. Use the optional-argument pattern to support both forms cleanly.Mistake 4: shared state through closure variables across decorations.def counter(func): count = 0; def wrapper(...) seems right, but assigning count inside wrapper rebinds a local variable. Use nonlocal count to mutate the closure, or switch to a class-based decorator.Mistake 5: stacking order assumptions.@cache on top of @retry caches the retry behavior including failures. Put @cache closest to the function (innermost) almost always.
Frequently Asked Questions
What is a Python decorator?
A decorator is a callable that takes a function and returns a replacement function that wraps the original. The @decorator syntax above a function definition is sugar for func = decorator(func). The wrapper typically runs code before and after the original, modifying its behavior without changing the original function's source. Decorators apply to functions, methods, and (with @decorator on the class line) classes.
Why do I need functools.wraps in my decorator?
Without functools.wraps, the wrapper function's __name__, __doc__, __qualname__, __module__, __annotations__, and __dict__ replace the original function's. Tools that rely on these (debuggers, documentation generators, pytest, mock libraries, FastAPI's dependency injection) break or behave strangely. @functools.wraps(func) copies the metadata back to the wrapper and also sets __wrapped__ so you can still reach the original. Apply it to every decorator you write.
How do I write a decorator that takes arguments?
Wrap your decorator in another function that accepts the arguments and returns the actual decorator. So @retry(3) calls retry(3) first, which returns a decorator, which then receives and wraps the target function. The pattern is three nested functions: the outer accepts the decorator arguments, the middle is the decorator that receives the function, and the inner is the wrapper that takes the function's arguments. For decorators that should work both with and without arguments, use the optional-argument pattern that branches on whether the first argument is callable.
Can I decorate an async function?
Yes, but the wrapper must be async too. A synchronous wrapper around an async function returns a coroutine object instead of awaiting it. Define the wrapper with async def and await the inner call: return await func(*args, **kwargs). For decorators that should support both sync and async targets, check asyncio.iscoroutinefunction(func) and return different wrappers; the cleanest production pattern uses two branches inside the decorator factory.
How do I add types to a Python decorator?
Use typing.ParamSpec and typing.TypeVar (PEP 612, Python 3.10+). ParamSpec captures the entire signature of the wrapped function, while TypeVar captures the return type. The wrapper's type is then Callable[P, R], which preserves the signature for static type checkers. Before 3.10, decorators were essentially un-typeable; ParamSpec is the first solution that works correctly for the general case.
The Bottom Line: Wrappers, Sugar, and the Modern Toolkit
A decorator is a function transformer: takes a function, returns a wrapping function. The @ syntax is sugar for the manual reassignment. functools.wraps keeps the wrapper from breaking everything that introspects the function. Decorators with arguments add a layer; optional arguments add a branch. Class-based decorators win for stateful behavior; async decorators need an async wrapper; PEP 612's ParamSpec finally lets you type the result. Every decorator costs a function call, which is invisible for real work and visible for tight loops. Keep the wrapping mental model in your head, apply functools.wraps by reflex, and the rest is variations on the same shape. For the rest of the most-asked Python concept questions, browse the full Python Concepts Explained index.
Drill Decorator Patterns Until They're Reflex
CodeGym's Python track turns decorator patterns into muscle memory through 800+ hands-on tasks across 62 levels. The AI validator checks every submission in seconds; the AI mentor explains what broke when you get stuck. First level free; full plan on the pricing page.
Learn Python with hands-on practice →
GO TO FULL VERSION