@functools.wraps(func) is a one-line addition to any custom decorator that preserves the original function's __name__, __qualname__, __module__, __doc__, __annotations__, and (since Python 3.12) __type_params__, while also merging the original function's __dict__ into the wrapper. It does not copy __defaults__ or __kwdefaults__ — default values are accessible at runtime through the __wrapped__ chain that @functools.wraps also sets. Without it, every decorated function loses its identity and presents itself as the inner wrapper. This guide shows you exactly where to place it in every type of decorator you will write — simple decorators, parameterized decorators, class-based decorators, stacked chains, and async wrappers — with copy-ready boilerplate for each.
The Import
Before using @functools.wraps, you need to import the module. There are two common styles:
# Style 1: import the module
import functools
# Usage: @functools.wraps(func)
# Style 2: import the function directly
from functools import wraps
# Usage: @wraps(func)
Both are equivalent. This article uses import functools for clarity so that every usage is explicitly namespaced.
What functools.wraps actually copies
When you call @functools.wraps(func), it invokes functools.update_wrapper(wrapper, func) internally. That function performs two operations, as defined by two module-level constants in functools.py:
WRAPPER_ASSIGNMENTS — attributes copied directly from the original function onto the wrapper. As of Python 3.12, the full tuple is: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. The __type_params__ attribute was added in Python 3.12 to support PEP 695 generic type parameter syntax. Note that __defaults__ and __kwdefaults__ are not part of WRAPPER_ASSIGNMENTS — default argument values are not copied, which means inspect.signature() resolves correct default values by following the __wrapped__ chain rather than reading the wrapper's own defaults.
WRAPPER_UPDATES — attributes merged (not replaced) from the original into the wrapper: __dict__. This means any attributes already on the wrapper are preserved and the original's attributes are merged in.
Additionally, update_wrapper always sets __wrapped__ on the wrapper to point at the original function. This attribute was added in Python 3.2. inspect.signature() follows __wrapped__ chains by default when resolving parameter signatures.
A less commonly documented behavior: __wrapped__ chains can be traversed manually using inspect.unwrap(func), which follows the chain until it reaches a function with no __wrapped__ attribute. This lets you recover the original unwrapped callable even through multiple layers of decoration, as long as every decorator in the chain uses @functools.wraps. The Python documentation notes that inspect.signature() follows __wrapped__ links by default — if you pass follow_wrapped=False, it will reflect the wrapper's own signature instead.
"Without the use of this decorator factory, the name of the example function would have been 'wrapper', and the docstring of the original example() would have been lost." — Python standard library documentation, functools.wraps
The official functools documentation makes the point plainly: without @functools.wraps, every decorated function silently impersonates the wrapper instead of the original.
The complete list of these constants is in the Python standard library docs for functools.update_wrapper. Both wraps() and update_wrapper() accept optional assigned and updated tuple arguments if you need to customize which attributes are copied.
Placement in a Simple Decorator
A simple decorator has two layers: an outer function that receives the target function, and an inner wrapper function that replaces it. The @functools.wraps(func) line goes directly above the def line of the wrapper:
import functools
def log_calls(func): # Outer: receives the function
@functools.wraps(func) # <-- placed here
def wrapper(*args, **kwargs): # Inner: replaces the function
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Add two integers.
The rule is mechanical: @functools.wraps(func) goes on whichever function gets returned as the replacement. In a simple decorator, that is always the wrapper.
The Universal Boilerplate
Every simple decorator you write can start from this template. Copy it, rename the decorator, and add your logic in the marked locations:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# YOUR PRE-CALL LOGIC HERE
result = func(*args, **kwargs)
# YOUR POST-CALL LOGIC HERE
return result
return wrapper
Save this boilerplate as a snippet in your editor. Every new decorator you write starts from it. The three things that change each time are the decorator name, the pre-call logic, and the post-call logic. The @functools.wraps(func) line and the *args, **kwargs pattern stay the same every time.
Placement in a Parameterized Decorator
A parameterized decorator -- one that accepts its own configuration arguments like @retry(max_tries=3) -- has three nesting layers. The @functools.wraps(func) still goes on the innermost function:
import functools
import time
def retry(max_tries=3, delay=1.0): # Layer 1: captures config
def decorator(func): # Layer 2: receives the function
@functools.wraps(func) # <-- placed here, on layer 3
def wrapper(*args, **kwargs): # Layer 3: replaces the function
last_exc = None
for attempt in range(1, max_tries + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt < max_tries:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(max_tries=4, delay=0.5)
def fetch_data(url: str) -> dict:
"""Fetch JSON data from the given URL."""
pass
print(fetch_data.__name__) # fetch_data
print(fetch_data.__doc__) # Fetch JSON data from the given URL.
The argument to @functools.wraps is the func parameter from the layer directly above. In a simple decorator, the outer function receives func and the @wraps argument is func. In a parameterized decorator, the middle function receives func and the @wraps argument is still func. The pattern is always: @wraps receives the function parameter from its enclosing scope.
Placement in a Class-Based Decorator
Class-based decorators use __init__ to receive the function and __call__ to run the wrapper logic. Since there is no standalone wrapper function to decorate with @functools.wraps, you call functools.update_wrapper directly inside __init__:
import functools
class CountCalls:
"""Track how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func) # <-- placed here
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__} called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def greet(name: str) -> str:
"""Return a personalized greeting."""
return f"Hello, {name}"
print(greet.__name__) # greet
print(greet.__doc__) # Return a personalized greeting.
functools.update_wrapper(self, func) does the same work as @functools.wraps(func). In fact, @functools.wraps(func) is just a convenience decorator that calls update_wrapper internally. The class-based form uses the direct call because there is no function definition to decorate.
Placement in an Async Decorator
Async decorators follow the same placement rule as synchronous ones. The only difference is that the wrapper is defined with async def and uses await:
import functools
import asyncio
import time
def async_timer(func):
@functools.wraps(func) # <-- same placement as sync
async def wrapper(*args, **kwargs):
start = time.perf_counter()
result = await func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@async_timer
async def fetch_user(user_id: int) -> dict:
"""Fetch user data asynchronously."""
await asyncio.sleep(0.1) # Simulating async I/O
return {"id": user_id, "name": "Alice"}
print(fetch_user.__name__) # fetch_user
print(fetch_user.__doc__) # Fetch user data asynchronously.
@functools.wraps(func) works identically on async def functions. It copies the same attributes regardless of whether the wrapper is a coroutine or a regular function.
@functools.wraps preserves runtime metadata, but it does not preserve type information for static analysis tools like mypy or pyright. Because the wrapper uses *args, **kwargs, type checkers see those generic parameter types — not the original function's typed signature. This means type checkers may miss argument errors when calling the decorated function. This is a known limitation of functools.wraps. The solution is to combine it with ParamSpec from the typing module — see the Type-Safe Decoration with ParamSpec section below. If you prefer a drop-in replacement instead, the third-party tightwrap package applies functools.wraps under the hood while also working correctly with static analysis tools.
Making the Static Typing Limitation Go Away: ParamSpec
The typing limitation mentioned above has a solution built into the standard library since Python 3.10: ParamSpec from the typing module (PEP 612). Combining @functools.wraps with ParamSpec and TypeVar gives you runtime metadata preservation and static analysis accuracy at the same time.
ParamSpec is a special type variable that captures a function's entire parameter specification — not just the types of individual arguments, but the full signature including positional and keyword parameter structure. When you type your decorator as Callable[P, R] -> Callable[P, R], type checkers like mypy and pyright know that the returned function has the same call signature as the input function. This is the piece that @functools.wraps alone cannot provide.
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P") # Captures the full parameter specification
R = TypeVar("R") # Captures the return type
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
# Type checkers now know: add(a: int, b: int) -> int
# This will be flagged as a type error by mypy/pyright:
# add("wrong", "types")
The key difference in the wrapper signature is *args: P.args, **kwargs: P.kwargs instead of the plain *args, **kwargs. Using P.args and P.kwargs tells the type checker to treat those parameters as having exactly the types captured from the original function. This is how the type information threads through the decorator layer.
On Python 3.12 and later, PEP 695 introduced an even more compact inline syntax. You can declare the parameter spec and return type directly in the function signature using square brackets, eliminating the need for the separate ParamSpec and TypeVar declarations:
# Python 3.12+ inline type parameter syntax (PEP 695)
# No separate ParamSpec("P") or TypeVar("R") declarations needed
import functools
from typing import Callable
def log_calls[**P, R](func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
If your codebase runs mypy or pyright in strict mode, or if the decorated functions are part of a public API where callers depend on IDE autocompletion for parameter names, combine @functools.wraps with ParamSpec. For internal utility decorators where type accuracy is less critical, plain @functools.wraps is sufficient and simpler.
There is a subtle but important constraint: P.args and P.kwargs must always appear together in the wrapper signature. You cannot use P.args without P.kwargs. Splitting them causes a type error at check time, even though Python itself would not raise an exception at runtime. The Python typing specification treats them as an inseparable pair that represents the full parameter set.
Placement in Stacked Decorator Chains
When multiple decorators are stacked on the same function, every decorator in the chain needs its own @functools.wraps(func). Each decorator receives a function (which may already be a wrapper from the decorator below) and returns a new wrapper. Each layer must copy metadata from whatever it receives:
import functools
def timer(func):
@functools.wraps(func) # Each decorator needs its own @wraps
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
def log_calls(func):
@functools.wraps(func) # Each decorator needs its own @wraps
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
@timer
def multiply(x: int, y: int) -> int:
"""Multiply two integers."""
return x * y
print(multiply.__name__) # multiply
print(multiply.__doc__) # Multiply two integers.
If timer uses @wraps but log_calls does not, the metadata breaks at log_calls. If log_calls uses @wraps but timer does not, the metadata breaks at timer and log_calls copies the broken metadata from timer's wrapper. Every link in the chain must participate for the original metadata to survive to the outermost layer.
If a third-party decorator in your stack does not use @functools.wraps, it will break metadata propagation for the entire chain above it. You cannot fix this from outside the decorator without replacing or wrapping it.
When You Do Not Need @wraps
There is one category of decorator where @functools.wraps is unnecessary: decorators that return the original function unmodified. A registration decorator, for example, stores a reference to the function and then returns it without wrapping it in a new function:
REGISTRY = {}
def register(func):
"""Register a function in the global registry."""
REGISTRY[func.__name__] = func
return func # Returns the ORIGINAL function, not a wrapper
@register
def process():
"""Process data."""
pass
print(process.__name__) # process (no wrapper involved, no @wraps needed)
Because the original function is returned unchanged, there is no wrapper to copy metadata onto. The function's identity is never replaced. This is the only case where @functools.wraps is not needed. If a decorator returns a new function object, it needs @wraps.
Common Placement Mistakes
Missing parentheses: @functools.wraps instead of @functools.wraps(func)
# WRONG -- silently produces wrong behavior in Python 3 (no exception raised)
def my_decorator(func):
@functools.wraps # Missing (func) !
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# RIGHT
def my_decorator(func):
@functools.wraps(func) # Correct
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Without the parentheses, Python applies functools.wraps itself as a decorator. Because wraps is implemented as partial(update_wrapper, wrapped=wrapped, ...), writing @functools.wraps calls wraps(wrapper), which treats wrapper as the wrapped argument. The result is update_wrapper(wrapper, wrapped=wrapper) — the wrapper copies its own metadata onto itself. No exception is raised in Python 3, but the original function's attributes are never transferred. The decorated function still carries the wrapper's name and docstring instead of the original's, which is the exact problem @functools.wraps is supposed to solve.
Placed on the wrong layer in a parameterized decorator
# WRONG -- @wraps on the middle layer
def repeat(n):
@functools.wraps # Wrong layer, and missing (func)
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
# RIGHT -- @wraps on the innermost layer
def repeat(n):
def decorator(func):
@functools.wraps(func) # Correct layer and correct argument
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
Applied to the decorator function instead of the wrapper
# WRONG -- @wraps on the outer function
@functools.wraps # This makes no sense here
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# RIGHT -- @wraps on the wrapper
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Verifying Correct Placement
After adding @functools.wraps(func) to your decorator, verify with this four-line check:
import inspect
@my_decorator
def sample(x: int, y: int = 10) -> int:
"""A sample function for verification."""
return x + y
assert sample.__name__ == "sample", f"Name wrong: {sample.__name__}"
assert sample.__doc__ == "A sample function for verification.", f"Doc wrong: {sample.__doc__}"
assert hasattr(sample, '__wrapped__'), "__wrapped__ missing"
assert str(inspect.signature(sample)) == "(x: int, y: int = 10) -> int", "Signature wrong"
print("All metadata preserved correctly.")
If any assertion fails, the decorator is not using @functools.wraps correctly. The four checks cover the function name, docstring, __wrapped__ attribute presence, and the complete parameter signature including type annotations and defaults.
Deeper Solutions and Production Patterns
The standard decorator template handles typical cases, but production codebases surface requirements that the basic approach does not address. The patterns below represent solutions that go beyond placement — they address structural problems that arise at scale.
Selective attribute copying when the wrapper changes the return type
The default WRAPPER_ASSIGNMENTS tuple includes __annotations__. If your decorator intentionally changes the return type — for example, a caching decorator that returns a typed CacheResult wrapper instead of the original return value — inheriting __annotations__ from the original function is misleading. Runtime introspection and documentation generators will display the wrong return type. The solution is to exclude __annotations__ from the copy using the assigned parameter:
import functools
from dataclasses import dataclass
from typing import Any
@dataclass
class CacheResult:
value: Any
hit: bool
# Exclude __annotations__ -- our wrapper has a different return type
_ASSIGNMENTS_NO_ANNOTATIONS = tuple(
a for a in functools.WRAPPER_ASSIGNMENTS if a != '__annotations__'
)
def cache_with_result(func):
@functools.wraps(func, assigned=_ASSIGNMENTS_NO_ANNOTATIONS)
def wrapper(*args, **kwargs):
# ... cache lookup logic ...
return CacheResult(value=func(*args, **kwargs), hit=False)
wrapper.__annotations__ = {'return': CacheResult} # set the correct annotation
return wrapper
@cache_with_result
def compute(x: int) -> int:
"""Compute a value."""
return x * x
print(compute.__name__) # compute
print(compute.__annotations__) # {'return': <class 'CacheResult'>}
print(compute.__doc__) # Compute a value.
This is more precise than either blindly inheriting misleading annotations or stripping them entirely. The decorator communicates an accurate type contract at runtime.
Restoring metadata broken by third-party decorators
A third-party decorator in your stack that does not use @functools.wraps will break the metadata chain at that layer, and you cannot fix it from inside the third-party source. The practical solution is to restore metadata after the fact using functools.update_wrapper directly against the original function:
import functools
# Assume some_decorator does not use @functools.wraps internally
from third_party_lib import some_decorator
def apply_with_metadata(decorator, func):
"""Apply a decorator and restore metadata regardless of whether
the decorator uses @wraps internally."""
decorated = decorator(func)
functools.update_wrapper(decorated, func)
return decorated
def process(x: int) -> int:
"""Process a value."""
return x * 2
process = apply_with_metadata(some_decorator, process)
print(process.__name__) # process
print(process.__doc__) # Process a value.
This pattern is useful when upgrading a third-party dependency is not yet feasible but correct introspection is required for documentation generation, logging, or test tooling.
Building a decorator factory that enforces @wraps by construction
When a team writes many decorators, inconsistent @wraps usage is a maintenance risk. A decorator factory that enforces correct metadata handling by construction eliminates the entire class of placement mistakes — developers supply only the wrapper logic, and the factory handles the boilerplate:
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def make_decorator(pre=None, post=None):
"""
Factory that produces a decorator from pre-call and post-call hooks.
@wraps, *args/**kwargs, and ParamSpec handling are enforced automatically.
"""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
if pre:
pre(func, args, kwargs)
result = func(*args, **kwargs)
if post:
post(func, result)
return result
return wrapper
return decorator
# Usage: no @wraps boilerplate needed at the call site
log_entry = make_decorator(pre=lambda f, a, k: print(f"entering {f.__name__}"))
count_exit = make_decorator(post=lambda f, r: print(f"{f.__name__} returned"))
@log_entry
@count_exit
def fetch(url: str, timeout: int = 30) -> dict:
"""Fetch data from a URL."""
return {}
print(fetch.__name__) # fetch
print(fetch.__doc__) # Fetch data from a URL.
Walking and auditing the __wrapped__ chain at runtime
In a live application with multiple decorator layers, a chain-walking utility lets you inspect which layers are present and identify any link that is missing @wraps. A layer that was decorated without @wraps will show the wrapper's own name rather than the original function's name:
def walk_wrapper_chain(func):
"""
Walk the __wrapped__ chain and print each layer.
If a layer is missing __wrapped__, that decorator did not use @functools.wraps.
"""
layer = 0
current = func
while True:
print(f" Layer {layer}: {current.__name__!r} ({type(current).__name__})")
if not hasattr(current, '__wrapped__'):
print(f" Chain ends at layer {layer}.")
break
current = current.__wrapped__
layer += 1
# A correctly wrapped chain shows the original name at every layer.
# A layer that shows 'wrapper' or 'inner' means @functools.wraps was omitted there.
Combining this with inspect.unwrap(func) provides a programmatic check: if inspect.unwrap stops earlier than expected, the layer where the chain breaks is the decorator missing @functools.wraps. This is particularly useful in test suites that verify decorator correctness across a large codebase.
Key Takeaways
- Import first:
import functools. Thewrapsfunction lives in thefunctoolsstandard library module. No installation is needed -- it ships with Python. - Place
@functools.wraps(func)directly above the wrapperdefline. The wrapper is the innermost function that replaces the original in the namespace. This is true for simple, parameterized, and async decorators alike. - The argument is always the function from the enclosing scope. In a simple decorator, the outer function receives
func. In a parameterized decorator, the middle function receivesfunc. The@wrapsargument is always thatfuncparameter. - For class-based decorators, call
functools.update_wrapper(self, func)in__init__. This is the explicit-call form of@wrapsand works the same way on class instances. - Every decorator in a stacked chain needs its own
@wraps. Metadata propagation is a chain -- each decorator copies metadata from what it receives, so every link must participate. - Skip
@wrapsonly when returning the original function unmodified. Registration decorators that store a reference and return the function as-is do not create a wrapper, so there is nothing to copy metadata onto. - Verify with four assertions. Check
__name__,__doc__,hasattr(__wrapped__), andinspect.signature(). If all four pass, the decorator is correctly preserving metadata. - On Python 3.12+,
__type_params__is also copied. If you are using PEP 695 type parameter syntax,functools.wrapscopies__type_params__automatically as of Python 3.12. No additional configuration is needed. @functools.wrapsis runtime-only — it does not fix static type checking. Type checkers likemypyandpyrightstill see the wrapper's*args, **kwargssignature. This is a known limitation offunctools.wraps.- Combine with
ParamSpecfor both runtime and static type accuracy. UsingP = ParamSpec("P")andR = TypeVar("R")with a decorator typed asCallable[P, R] -> Callable[P, R], along with*args: P.args, **kwargs: P.kwargsin the wrapper, gives type checkers the full original signature. This requires Python 3.10+ ortyping_extensions. On Python 3.12+, the PEP 695 inline syntaxdef my_decorator[**P, R](func: Callable[P, R]) -> Callable[P, R]eliminates the separate variable declarations entirely.
The placement rule for @functools.wraps(func) is consistent across every decorator variant: it goes on the function that replaces the original. Once that rule is internalized, applying it becomes automatic. Every decorator template in this article -- simple, parameterized, class-based, async, and stacked -- follows the exact same pattern with the exact same result: the decorated function keeps its original identity.