Python Decorators, Explained as a Name Swap
A Python decorator is a function that returns another function, usually applied with @wrapper syntax so the returned callable replaces the original name.

A Python decorator is a function that returns another function, usually applied with @wrapper syntax so the returned callable replaces the original name.

A Python decorator is a function that returns another function, usually applied with @wrapper syntax so the returned callable replaces the original name. Python's glossary names @classmethod and @staticmethod as the everyday examples. Function @ syntax shipped in Python 2.4 (PEP 318); the name comes from compiler annotation, not the Gang of Four object-wrapper pattern.
You already type this shape on FastAPI routes and on stdlib helpers like @property. The recurring mix-up, on r/learnpython, is treating the decorator (which runs at definition time) as if it were the wrapper (which runs later, on each call).
@deco on a def means the name is rebound to deco(original) when the def runs.functools.wraps copies identity metadata (`name, doc, wrapped). It does not give a args, *kwargs` wrapper the original runtime signature.@decorator(arg) needs a three-layer factory because @expr always calls expr(function).A = deco(A)), or use a class as a decorator (`init plus call`).f = staticmethod(f) and @staticmethod above def f are the same rewrite. The @ spelling exists because the assignment form names the function three times.
classmethod and staticmethod themselves shipped earlier, in Python 2.2, as post-assignment wrappers. PEP 318 (created 5 June 2003; authors Kevin Smith, Jim Jewett, Skip Montanaro, and Anthony Baxter) added the pie-syntax @ so you write the name once.
PEP 318's own section "On the name 'Decorator'" says the name is not consistent with the Gang of Four book. It owes more to compilers, where a syntax tree is walked and annotated. If a tutorial calls them "a structural design pattern which wrap the original object," that is a different idea and a different search.
The same @ rewrite exists for classes, and is less commonly used there. It is not a 3.0-only feature.
If you are still on Python basics, treat @ as a name swap you will meet again in frameworks. You do not need a design-pattern catalog first.
Decorator expressions run when the function is defined, in the containing scope. The result must be a callable, invoked with the function object as its only argument. The returned value is bound to the function name instead of the original function object.
The language rewrite is the whole mechanism. From the compound statement reference:
@f1(arg)
@f2
def func():
passis equivalent to:
def func():
pass
func = f1(arg)(f2(func))except the original function is never temporarily bound to func. Multiple decorators nest. The same rewrite applies to classes.
From Python 3.9, PEP 614 loosened the grammar so the expression after @ can be any valid expression.
Four beats, then stop. Functions are first-class values, so you can pass them and return them. The decorator runs at def time, not when you later call the name.
The decorator's return value replaces the name. The replacement usually closes over the original function so it can still call it.
That last beat is why nested functions show up in every example. The inner function is the wrapper you call. The outer function is the decorator you applied.
Closures are the storage: the wrapper remembers func.
Reuven M. Lerner teaches the same reassignment in "Practical decorators" (PyCon 2019, 1:33):
"What does def do in Python? It actually does two different things: it creates a function object and it also assigns that object to the identifier. Right after I define my function, it then does a reassignment: call my deco with an argument of add and assign the result back to the variable add."
On r/learnpython, ELI5 threads stall when people mix those two moments. They can paste @app.get and still not say when it runs. The rewrite bar = foo(bar) is the answer that lands.
Katie Silverio's one-line version matches the language reference. In "Decorators, unwrapped: How do they work?" (PyCon 2017, 16:56):
"The at decorator syntax, when used to decorate a function, is just syntactic sugar for replacing that function with the return value of a callable object called with a decorated function as an argument. That's it."
Closest to the def is applied first at definition time. @f1(arg) @f2 def func means f1(arg)(f2(func)). When you later call func, wrappers run outside-in: the top decorator in source is the first wrapper that sees the call.
Call-time order is outside-in: @make_bold above @make_italic above hello wraps the result as "<b><i>hello world</i></b>".
Flask @login_required versus @app.route is the same rule in the wild. Put the route factory on the outside if the auth decorator should wrap the view, not the registration.
These are shapes of the same rewrite, not a ranked list.
Type | Best For | Key Characteristics |
|---|---|---|
Function wrapper | Logging, timing, retry | Nested function closes over original |
Parametrized factory | Routes, timeouts, retries with config | Factory returns a decorator |
Class as decorator | Stateful wrappers (call counts) | `init |
Decorator on a class |
|
|
Registration | FastAPI / Flask routes | Factory records the function, often returns it unchanged |
You already type several stdlib names: @property, @classmethod, @staticmethod, @dataclass, @lru_cache / @cache, and @functools.wraps. Those names are on-ramps.
Without help, a nested wrapper advertises itself. `func.name becomes 'wrapper', and help(func)` shows the wrapper's docstring. Tests and logs that key on the original name break.
functools.wraps is partial(update_wrapper, wrapped=…). It copies identity metadata from the wrapped function onto the wrapper, then adds `wrapped` so tools can follow the chain.
Default WRAPPER_ASSIGNMENTS: `module, name, qualname, annotations, type_params (3.12+), and doc. WRAPPER_UPDATES copies dict. Version notes on the live docs: wrapped and annotations in 3.2; from 3.4, wrapped` is always the wrapped function (bpo-17482); `type_params` in 3.12.
from functools import wraps
def traced(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapperSilverio's PyCon 2017 talk states the honest wraps claim (25:25): it replaces the wrapper's name and docstring with the wrapped function's, and it is itself applied as a decorator. That is identity. It is not a signature rewriter.
Hynek Schlawack's Please Fix Your Decorators is the part most tutorials skip. Even with @wraps, a `args, *kwargs` wrapper's runtime signature is the wrapper's.
inspect.signature follows `wrapped from Python 3.5, which helps introspection, and still does not fix class methods. Stack @decorator then @classmethod and you can get TypeError: 'classmethod' object is not callable`.
wrapt (Graham Dumpleton; GitHub 29 May 2013; 2,285 stars; PyPI v2.4.0) is the descriptor-aware library for that hole.
Callable[..., R] cannot forward the original parameter types. PEP 612 added ParamSpec and Concatenate in Python 3.10 so a decorator can keep the wrapped callable's parameters in the type checker.
See the typing docs. That is why "wraps preserves the signature" is still the wrong sentence.
@expr always calls expr(function). One argument: the function object. So @decorator(arg) must evaluate decorator(arg) first, and that result must itself be a decorator.
That is why you see three nested defs. The outer function is a factory. It closes over arg, then returns a normal decorator, which returns a wrapper.
Three layers is the language cost of @expr(function), not a name in the stdlib.
from functools import wraps
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def ping():
print("pong")@repeat(3) calls repeat(3) at definition time. The return value is decorator, which then receives ping.
The same factory mix-up shows up with timeouts:
"timeout() is NOT the decorator; it's a function that makes a decorator and returns it." (u/socal_nerdtastic in r/learnpython, Feb 2026)
@dataclass works with or without (). Bare @decorator versus @decorator() is the extra check factory pages skip.
Because @expr always calls expr(function), a factory that wants both forms has to detect what it received. @repeat passes the function into repeat. @repeat(3) passes 3 into repeat and expects a decorator back.
If you forget the inner return, you get TypeError at definition time, not at call time.
Some factories do not wrap behavior at all. They register the function in a table and often return it unchanged. Flask @app.route('/foo') and FastAPI @app.get("/items/") need to know the path, so the decorator is produced by a call that captures the route.
FastAPI (Sebastián Ramírez, created 8 December 2018; 102,191 stars) is the registration example. Compare the two web stacks on FastAPI vs Flask and FastAPI vs Django.
The phrase "class decorator" collapses two jobs. Keep them apart.
@foo @bar class A means A = foo(bar(A)), the same rewrite as functions (PEP 3129, also in What's New in 2.6). The decorator receives a class object and returns a class object (or something you then use as the class).
Metaclasses are inherited. Class decorators are not.
Builtins that do this job: @dataclass, @total_ordering, @runtime_checkable.
from dataclasses import dataclass
@dataclass
class User:
name: str
id: intDaniel Roseman (named Django contributor) on a request to use a class decorator to forward dunders: use metaclasses or write `isub` instead. A class decorator is the wrong tool for operator forwarding (u/danielroseman in r/learnpython, Mar 2026).
Calling a class as @log_me still rewrites greet = log_me(greet). The call returns an instance. That instance has to be callable, so it needs `call, or later greet()` fails.
from functools import wraps
class CountCalls:
def __init__(self, func):
wraps(func)(self)
self.func = func
self.n = 0
def __call__(self, *args, **kwargs):
self.n += 1
return self.func(*args, **kwargs)The class is the decorator. The instance is the wrapper. State (self.n) lives on the instance, which is why people pick this shape.
Nested functions remain the default teaching form. A class used as a decorator cannot decorate some methods the way a nested function can.
The point is reuse of the same wrapper. "Python decorators are great when you want to use the same wrapper for multiple functions. This repeatedly-used wrapper is the decorator." (u/AlSweigart in r/Python, Nov 2025). Django's @login_required is the framework version of that idea.
Logging, timing, retry, and cache are the custom cases people write. Try @lru_cache / @cache before writing a cache decorator.
@app.get and @app.route record a function in a router. Without @, you would pass the function into a method and keep the name in two places, which is the bug PEP 318 was written to stop.
Auth, timeout, and benchmarking hang on the function as syntax instead of a block you have to remember inside the body.
The sugar hides when work happens. A decorator body that prints on import is running at def time.
A wrapper body that prints on request is running at call time. Mixing them is the ELI5 failure mode.
Keep the rewrite in your head. name = deco(name) at definition. name(*args) later.
Skip @wraps and you debug a function named wrapper. Add @wraps and you still do not have the original runtime signature on a generic wrapper.
Class methods remain a known break. Use @wraps always, ParamSpec if types matter, and wrapt if you are decorating methods and descriptors.
"Understanding decorators is great, but don't use them unless you need them. They are hard to reason about. Overuse of decorators causes maintainability problems." (u/BossOfTheGame in r/Python, Nov 2025)
A decorator you apply once is a nested function with extra syntax. Write the wrapper in the function until a second caller needs it. Async wrappers need async def in the wrapper if they await.

Scrapy is a crawl framework, not a parser. Learn the Engine loop, spiders, item pipelines, and when a requests + BeautifulSoup script still wins.

LlamaIndex for Python: RAG, agents, LlamaParse, LiteParse v2.1, vs LangChain, and the silent OpenAI fallback.

Pydantic AI v2: type-safe Python agents with structured outputs, DI, and Logfire. Covers v2.0.0 Capabilities, deterministic evals, and LangChain comparison.