A function-based decorator is the standard form of Python decoration: a plain function that accepts another function, wraps it with new behavior, and returns the result. Understanding this pattern — including what happens inside the closure, how CPython processes the @ syntax, and what functools.wraps copies under the hood — is essential before reaching for class-based or parameterized variants.
Python treats functions as first-class objects, which means a function can be passed as an argument, stored in a variable, and returned from another function. Decorators are built on exactly this property. When you write @my_decorator above a function definition, Python evaluates it as my_function = my_decorator(my_function) at import time. The original name now points to whatever my_decorator returned — in the typical case, a wrapper function that calls the original internally.
The anatomy of a function decorator
A minimal function-based decorator has three components: the outer decorator function that receives the target function, an inner wrapper function that holds the new behavior, and a return statement that hands back the wrapper. The original function is captured in the closure formed by the outer function's scope, so the wrapper can call it at any point.
How to write a function-based decorator
def my_decorator(func):
def wrapper():
print("before the call")
func()
print("after the call")
return wrapper
@my_decorator
def greet():
print("hello")
greet()
# before the call
# hello
# after the call
The @my_decorator line above greet is syntactic sugar. Python runs greet = my_decorator(greet) immediately when the module loads. After that, greet refers to wrapper, and calling greet() invokes the wrapper body.
Think of a decorator like a sandwich wrapper at a deli. When you order a wrap, the counter worker takes your filling (greet), wraps it in a tortilla (wrapper), and hands you back the wrapped version. From that point on, every time you eat it, you go through the tortilla first — then the filling — then back out through the tortilla. The filling itself never changed; only what surrounds it did.
The decorator runs once at definition time, not each time the decorated function is called. The wrapper it returns is what runs on every call. This distinction matters when the decorator performs expensive setup work.
Returning values from the wrapper
The example above works for functions that return nothing. If the target function returns a value, the wrapper must capture and pass it back to the caller. A wrapper that does not return anything implicitly returns None, silently discarding the original return value.
def log_call(func):
def wrapper():
print(f"calling {func.__name__}")
result = func()
print(f"{func.__name__} returned {result!r}")
return result # forward the return value
return wrapper
@log_call
def get_version():
return "3.12"
v = get_version()
# calling get_version
# get_version returned '3.12'
print(v) # 3.12
Always write return result at the end of your wrapper. Even if the function you are decorating today returns None, future changes may add a return value. A wrapper that forwards the result is correct in both cases.
How the closure actually works
The wrapper function returned by a decorator is a closure: a function object bundled together with references to variables from the enclosing scope that it captured at definition time. In the decorator pattern, the captured variable is almost always func — the original function passed in as an argument. Python stores these captured references in an internal structure called cell objects, accessible via the function's __closure__ attribute.
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def add(a, b):
return a + b
# Inspect the closure cells
print(add.__closure__)
# (<cell at 0x...: function object at 0x...>,)
print(add.__closure__[0].cell_contents)
# <function add at 0x...> — the original unwrapped function
Each cell in __closure__ holds a reference to one captured variable. Calling cell_contents on a cell reveals what it holds. For a decorator, that is the original function — which is exactly what __wrapped__ also exposes, though via a different mechanism. The cell objects are created when the outer decorator function executes, which is why decoration happens at import time rather than at call time.
This distinction has a practical consequence: if your decorator does something expensive — reading a config file, compiling a regex, importing a module — that cost is paid once when the decorated function is defined, not once per call. Placing that work outside the wrapper is a deliberate optimization you can exploit.
import re
import functools
# regex is compiled ONCE at decoration time, not per call
def validate_email(func):
_pattern = re.compile(r'^[^@]+@[^@]+\.[^@]+$') # runs once
@functools.wraps(func)
def wrapper(email, *args, **kwargs):
if not _pattern.match(email):
raise ValueError(f"Invalid email: {email!r}")
return func(email, *args, **kwargs)
return wrapper
@validate_email
def send_message(email, body):
print(f"Sending to {email}: {body}")
The _pattern regex is compiled when the module loads and captured in the closure. Every subsequent call to send_message reuses the same compiled pattern object without re-running re.compile.
The late-binding gotcha in decorator loops
Closures in Python capture variable references, not values. This distinction is invisible in the standard decorator pattern because the captured variable (func) is a function argument — its value is fixed at the moment the decorator is called. The problem surfaces when you build multiple decorators inside a loop using a loop variable as the captured value.
# BUG: all three wrappers print "c" because they share the same cell
validators = []
for label in ['a', 'b', 'c']:
def check(func):
def wrapper(*args, **kwargs):
print(label) # captures the cell, not the value
return func(*args, **kwargs)
return wrapper
validators.append(check)
# FIX: force early binding with a default argument
validators = []
for label in ['a', 'b', 'c']:
def check(func, _label=label): # _label captures the value now
def wrapper(*args, **kwargs):
print(_label)
return func(*args, **kwargs)
return wrapper
validators.append(check)
The default-argument trick works because default values are evaluated at function definition time, creating a new binding for each iteration. This is a niche scenario in decorator authoring, but it surfaces frequently enough in frameworks and plugin registries to be worth knowing before you encounter it.
Forwarding arguments and preserving metadata
The simple wrapper above only works for functions that accept no arguments. A general-purpose decorator must accept any combination of positional and keyword arguments and forward them unchanged. The Python convention is *args, **kwargs in the wrapper signature, forwarded directly to the original function call.
def timer(func):
import time
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} ran in {elapsed:.6f}s")
return result
return wrapper
@timer
def add(a, b):
return a + b
total = add(3, 7)
# add ran in 0.000001s
print(total) # 10
The wrapper here accepts any arguments, passes them through with func(*args, **kwargs), and returns whatever the original function returns. This pattern makes the decorator signature-agnostic: it will work on any function without modification.
Preserving function metadata with functools.wraps
When Python replaces the original function with the wrapper, the wrapper's own __name__, __doc__, __annotations__, and other dunder attributes replace the original ones. This breaks introspection tools, documentation generators, and anything that reads func.__name__ at runtime.
def naive_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@naive_decorator
def process():
"""Process data."""
pass
print(process.__name__) # wrapper (wrong)
print(process.__doc__) # None (lost)
functools.wraps fixes this. Applying it as a decorator on the wrapper function copies the original's metadata onto the wrapper, so callers and tools see the correct name and docstring.
import functools
def robust_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@robust_decorator
def process():
"""Process data."""
pass
print(process.__name__) # process (correct)
print(process.__doc__) # Process data.
Omitting @functools.wraps will cause failures in any code that inspects function names at runtime, including logging frameworks that use func.__name__, test runners that report by function name, and Python's own help() built-in.
Comparison: with and without functools.wraps
- Without functools.wraps
- "wrapper"
- With functools.wraps
- Original function name
- Without functools.wraps
- None (lost)
- With functools.wraps
- Original docstring
- Without functools.wraps
- Wrapper's annotations (empty)
- With functools.wraps
- Original type annotations
- Without functools.wraps
- Not set
- With functools.wraps
- Reference to original function
The __wrapped__ attribute added by functools.wraps is particularly useful in testing: it lets you access the unwrapped original function directly via decorated_func.__wrapped__, so unit tests can call the core logic without triggering the decorator.
What functools.wraps copies — and what it does not
functools.wraps is a thin wrapper around functools.update_wrapper, which reads from two constants: WRAPPER_ASSIGNMENTS and WRAPPER_UPDATES. In Python 3.12, __type_params__ was added to WRAPPER_ASSIGNMENTS to support PEP 695 type parameter syntax on generic functions. The full set copied by assignment is __module__, __name__, __qualname__, __doc__, __annotations__, and __type_params__ (3.12+). The wrapper's __dict__ is updated (not replaced) with the wrapped function's __dict__. The complete list is documented in the functools.update_wrapper reference.
The version history matters here. The __wrapped__ attribute was added in Python 3.2 — the same release that also began copying __annotations__ by default and stopped raising AttributeError when a wrapped attribute is missing from the source. Before Python 3.2, decorating a function stripped annotations silently. Before 3.4, if the wrapped function itself defined a __wrapped__ attribute, the chain could break; that was fixed so the attribute always points to the immediate wrapped function regardless. Understanding these version boundaries matters in codebases that still need to support older Python environments.
What most resources omit: functools.wraps accepts its own assigned and updated parameters, letting you customize exactly what gets copied. If you need to transfer default argument values as well — for example, to keep them visible to an IDE — you can pass an extended tuple:
import functools
# The standard set (Python 3.12+)
# ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__',
# '__type_params__')
# Custom extended set that also preserves defaults and kwdefaults
MORE_ASSIGNMENTS = functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__')
def preserving_decorator(func):
@functools.wraps(func, assigned=MORE_ASSIGNMENTS)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@preserving_decorator
def greet(name: str = "world") -> str:
"""Return a greeting."""
return f"Hello, {name}"
print(greet.__defaults__) # ('world',) — now visible directly on the wrapper
This pattern is rare but surfaces in plugin systems or introspection-heavy frameworks where the wrapper needs to appear identical to the original function down to its default values. Note that inspect.signature() follows __wrapped__ automatically since Python 3.3, so for most real-world purposes the standard @functools.wraps(func) is sufficient and copying __defaults__ manually is only needed when something reads the attribute directly rather than going through inspect.
import functools
import inspect
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name: str = "world") -> str:
"""Return a greeting string."""
return f"Hello, {name}"
# __defaults__ is NOT on the wrapper itself
print(greet.__defaults__) # None (wrapper has *args/**kwargs)
print(greet.__wrapped__.__defaults__) # ('world',) — original defaults
# inspect.signature follows __wrapped__
print(inspect.signature(greet)) # (name: str = 'world') -> str
# unwrap the full chain
print(inspect.unwrap(greet)) # <function greet at 0x...>
inspect.unwrap() follows the __wrapped__ chain iteratively until it reaches a function with no further __wrapped__ attribute, returning the innermost original callable. This is useful when multiple decorators are stacked — you can reach the original function without manually walking .__wrapped__.__wrapped__. What few resources mention: inspect.unwrap() accepts a stop callable argument. If provided, it is called at each step of the chain, and unwrapping stops early when it returns a truthy value. This lets you halt at a specific intermediate wrapper rather than always going all the way to the bare function — useful in testing scenarios where you want to skip one decorator layer but not all of them.
"Without the decorator factory, the function name and docstring would be lost."— Python documentation, functools.wraps (docs.python.org)
Stateful decorators and nonlocal
A decorator wrapper ordinarily has no persistent state — each call is independent. When you need the wrapper to accumulate data across calls (a call counter, a cache, a rate limiter), you have two approaches: use a mutable container in the closure (a list or dict), or declare the counter variable with nonlocal. The distinction matters because Python's LEGB scope rules treat any variable that is assigned inside a function as local unless explicitly declared otherwise.
import functools
# Approach 1: mutable container (works in Python 2 and 3)
def count_calls_v1(func):
calls = [0] # list used as a mutable cell
@functools.wraps(func)
def wrapper(*args, **kwargs):
calls[0] += 1
print(f"call #{calls[0]}")
return func(*args, **kwargs)
return wrapper
# Approach 2: nonlocal (Python 3 only, cleaner)
def count_calls_v2(func):
count = 0
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal count # tell Python: count lives in the enclosing scope
count += 1
print(f"call #{count}")
return func(*args, **kwargs)
return wrapper
@count_calls_v2
def process(x):
return x * 2
process(1) # call #1
process(2) # call #2
process(3) # call #3
Without nonlocal count, the line count += 1 raises UnboundLocalError. Python sees the assignment and treats count as a local variable for the entire wrapper body. When it then tries to evaluate the right side (count + 1), the local variable has not been assigned yet. nonlocal tells Python to look in the enclosing scope instead, resolving to the count = 0 defined in the decorator function.
State attached to a closure-based wrapper is per decorated function, not global. Each call to the decorator creates a new closure with its own independent counter. If you need shared state across multiple decorated functions, use a class-based decorator or a module-level dict keyed by function identity.
Stacking decorators and execution order
Python allows multiple decorators to be applied to a single function by listing them one per line above the definition. The decorator closest to the def line wraps the function first. Each decorator above it wraps the result of the one below. Reading from bottom to top gives the wrapping order; reading from top to bottom gives the call order at runtime.
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("bold: before")
result = func(*args, **kwargs)
print("bold: after")
return result
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("italic: before")
result = func(*args, **kwargs)
print("italic: after")
return result
return wrapper
@bold
@italic
def render():
print("render")
render()
# bold: before
# italic: before
# render
# italic: after
# bold: after
At definition time Python evaluates this as render = bold(italic(render)). The italic wrapper is innermost, so it runs next to the actual render call. The bold wrapper is outermost, so it runs first and last.
A practical stacking example: logging and timing together
import functools
import time
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"[TIMER] {func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
@log_call
@timer
def fetch_data(url):
# simulate I/O
time.sleep(0.05)
return {"url": url, "status": 200}
response = fetch_data("https://example.com")
# [LOG] calling fetch_data
# [TIMER] fetch_data: 0.0501s
print(response) # {'url': 'https://example.com', 'status': 200}
Notice that func.__name__ inside both decorators prints fetch_data rather than wrapper, because each decorator's wrapper carries @functools.wraps(func). Without it, the timer wrapper would be labelled wrapper by the time log_call wraps it, and the log line would read [LOG] calling wrapper.
When you need a decorator that accepts its own configuration arguments — for example, @retry(times=3) — you add another layer of nesting: a factory function that accepts the arguments and returns the actual decorator. That pattern is covered separately in the parameterized decorators article.
Why the @ syntax looks like it does
The @ decorator syntax was not in Python from the start. Before it was introduced in Python 2.4 (released October 2004 via PEP 318), the only way to decorate a function was the explicit reassignment form:
# Python 2.2 / 2.3 style — before @ syntax
def my_function(x):
return x * 2
my_function = some_decorator(my_function) # easy to forget; easy to get wrong name
This was error-prone for two reasons: the function name had to be retyped, and the decoration line sat far from the def statement in long functions, making it easy to miss. The community debated the syntax extensively — PEP 318 documents hundreds of proposed alternatives including decorate blocks, pipe syntax, and prefix keywords. The discussion ran on python-dev intermittently from February 2002 through July 2004. Guido van Rossum took a shortlist of proposals to EuroPython 2004 in Gothenburg, where the decision was finalized. The syntax that won — @decorator immediately before the def line — drew its @ character from Java, where it had already been used in Javadoc comments and then adopted for Java annotations. PEP 318 acknowledges this lineage directly. Guido's final choice was guided by readability instinct rather than any formal evaluation: the statement is limited in what it can accept — arbitrary expressions do not work — and PEP 318 records that Guido preferred this because of a gut feeling.
Barry Warsaw named the result the "pie-decorator" syntax — partly because @ visually resembles a pie, and partly as a nod to the Pie-thon Parrot shootout running concurrently at the time. That name did not stick, but the syntax did. PEP 3129 extended decorator support to class definitions using the same @ syntax, targeting Python 2.6.
"Guido preferred this because of a gut feeling."— PEP 318, on the final @ decorator syntax choice (peps.python.org)
What the bytecode actually looks like
The @ syntax is syntactic sugar, but understanding exactly what CPython emits helps explain the runtime behavior. You can inspect the bytecode with the dis module:
import dis
def my_decorator(func):
return func
@my_decorator
def greet():
pass
dis.dis(greet)
For a decorated function definition, CPython evaluates the decorator expression first, then creates the function object with MAKE_FUNCTION, then calls the decorator with CALL (or CALL_FUNCTION in older versions), and finally stores the result with STORE_NAME. The decorated name is never bound to the raw function — only to the decorator's return value. This is the bytecode-level confirmation that @my_decorator above def greet is completely equivalent to greet = my_decorator(greet).
For stacked decorators, the decorators are loaded onto the stack from bottom to top, and then applied top-down as each CALL instruction executes. This is why the decorator closest to def wraps first — it is called first — even though it appears lowest in the source.
Run import dis; dis.dis(your_module) on any module to see all decorator calls in their bytecode form. It is one of the fastest ways to confirm execution order when a stacked decorator combination produces unexpected behavior.
Using functools.wraps with ParamSpec (Python 3.10+)
One limitation that surfaces in typed Python codebases is that functools.wraps preserves the function's metadata at runtime but loses type information at the type-checker level — specifically, the wrapper's signature shows as (*args: Any, **kwargs: Any) rather than matching the original. PEP 612 (Python 3.10) introduced ParamSpec to solve this. Combined with TypeVar, it lets you express that a decorator preserves both the argument types and return type of the wrapped function.
from __future__ import annotations
import functools
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P") # captures the parameter signature
R = TypeVar("R") # captures the return type
def log_call(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"[LOG] calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def add(x: int, y: int) -> int:
return x + y
# Type checker now knows add(x: int, y: int) -> int
# rather than add(*args: Any, **kwargs: Any) -> Any
result: int = add(1, 2) # passes type checking
The Callable[P, R] annotation on both the decorator parameter and its return type tells type checkers (mypy, Pyright) that the wrapper has exactly the same call signature as the wrapped function. This does not affect runtime behavior — functools.wraps still does the metadata copy work — but it eliminates false type errors in callers and preserves IDE autocompletion for decorated functions. This pattern is worth knowing in any codebase that takes static typing seriously, and it is absent from most decorator introductions.
Key Takeaways
- Three-part structure: Every function-based decorator consists of an outer function that receives the target, an inner wrapper function that adds behavior, and a
return wrapperstatement that hands back the replacement callable. - Always use *args and **kwargs: Define the wrapper with
*args, **kwargsand forward them withfunc(*args, **kwargs)so the decorator works with any function signature and always returns the original result. - Always apply functools.wraps: Decorate every wrapper with
@functools.wraps(func)to preserve the original function's__name__,__doc__,__annotations__, and to set__wrapped__for testing access. Note that__defaults__is intentionally not copied — access it viafunc.__wrapped__.__defaults__if needed. - Closures capture references, not values: The wrapper holds a reference to the cell object containing
func. Expensive one-time setup (compiling a regex, opening a config) belongs outside the wrapper so it runs once at decoration time. - Use nonlocal for stateful wrappers: If the wrapper needs to mutate an enclosing variable (a call counter, a cached result), declare it
nonlocalor use a mutable container. Assigning withoutnonlocalraisesUnboundLocalError. - Stacking order is bottom-up at definition, top-down at call time: The decorator immediately above the
defline wraps first; the topmost decorator's wrapper runs first during a call and last on return. Usedis.dis()to verify bytecode order when stacking produces unexpected output. - @ syntax arrived in Python 2.4: Before PEP 318, decoration required manual reassignment (
f = deco(f)). The current syntax was chosen for readability; CPython compiles it to the same bytecode as the explicit form. - Use ParamSpec for type-safe decorators (Python 3.10+): Annotating the decorator as
Callable[P, R] -> Callable[P, R]and usingP.args/P.kwargsin the wrapper tells type checkers the wrapper preserves the original signature. Without this, type checkers report the wrapper as(*args: Any, **kwargs: Any), hiding errors at call sites.
Function-based decorators cover the large majority of real-world use cases. Once the three-part pattern, closure mechanics, and argument-forwarding idiom are second nature, more advanced forms — parameterized decorators, class-based decorators, and descriptor-based decoration — are natural extensions of the same underlying model.