Applying @functools.wraps to a single decorator is straightforward. Applying it correctly across a stack of three, four, or five decorators where parameterized decorators, class-based decorators, and third-party decorators are all mixed together requires understanding how the __wrapped__ chain propagates, how inspect.unwrap() traverses it, and what breaks when a single link in the chain is missing. This article covers every edge case you will encounter when preserving metadata in complex decorator stacks.
Every decorator in a stack must use @functools.wraps(func). If even one decorator omits it, the __wrapped__ chain breaks at that point, and inspect.signature() stops at the broken link instead of reaching the original function. The rest of this article explains exactly how that chain works and how to ensure it stays intact.
How the __wrapped__ Chain Forms in Stacks
When functools.wraps is applied to a wrapper function, it copies metadata attributes from the original function and sets wrapper.__wrapped__ = func. In a multi-decorator stack, each decorator's wrapper stores a __wrapped__ reference to the function it received. Since decorators apply bottom-to-top, this creates a linked chain from the outermost wrapper back to the original function.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
def logger(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
def auth(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("authenticated"):
raise PermissionError("Not authenticated")
return func(user, *args, **kwargs)
return wrapper
@auth
@logger
@timer
def process_order(user, order_id, priority="normal"):
"""Process a customer order."""
return f"Order {order_id} processed at {priority} priority"
The __wrapped__ chain for process_order is:
# Outermost: auth's wrapper
process_order.__wrapped__
# -> logger's wrapper
process_order.__wrapped__.__wrapped__
# -> timer's wrapper
process_order.__wrapped__.__wrapped__.__wrapped__
# -> original process_order function
Each link in the chain carries the original function's __name__ and __doc__ because every decorator used @functools.wraps. The __wrapped__ attributes form a linked list that inspect.signature() and inspect.unwrap() traverse automatically.
inspect.unwrap() Traverses the Entire Chain
inspect.unwrap() follows the __wrapped__ chain to its end and returns the original, innermost function. It is the standard way to reach the unwrapped function programmatically, regardless of how many decorators are stacked.
import inspect
original = inspect.unwrap(process_order)
print(original)
# <function process_order at 0x...>
print(original.__name__)
# process_order
print(inspect.signature(original))
# (user, order_id, priority='normal')
# Call the original without any decorator behavior
result = original({"authenticated": True}, "ORD-001")
print(result)
# Order ORD-001 processed at normal priority
# (no auth check, no logging, no timing)
inspect.unwrap() also accepts an optional stop callback that halts the unwrapping early. inspect.signature() uses this internally: it stops unwrapping when it finds a function with a __signature__ attribute, which allows decorators to override the reported signature if needed.
inspect.unwrap() raises a ValueError if it detects a cycle in the __wrapped__ chain. This can happen if a decorator accidentally sets __wrapped__ to itself. In well-behaved decorators using functools.wraps, this never occurs.
@A @B @C on a function f, what does f.__wrapped__.__wrapped__ point to?Correct Placement in Every Decorator Type
The rule is always the same: @functools.wraps(func) goes on the function object that replaces the original function in the namespace. Where that function lives depends on the decorator pattern being used.
Basic Decorator (Two Levels)
import functools
def my_decorator(func):
@functools.wraps(func) # wraps goes HERE
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Parameterized Decorator (Three Levels)
import functools
def my_decorator(param):
def decorator(func):
@functools.wraps(func) # wraps goes HERE (innermost)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
A common mistake is placing @functools.wraps(func) on the decorator function (the middle level) instead of the wrapper function (the innermost level). The decorator function is not the one that replaces the original function. wrapper is. The metadata must live on wrapper.
my_decorator -> decorator -> wrapper), where does @functools.wraps(func) go?Class-Based Decorator
import functools
class MyDecorator:
def __init__(self, func):
functools.update_wrapper(self, func) # update_wrapper HERE
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
Class-based decorators use functools.update_wrapper(self, func) in __init__ because there is no inner function to apply the @functools.wraps decorator syntax to. update_wrapper is the function that wraps calls internally, so the effect is identical. It copies the same set of attributes and sets self.__wrapped__ = func.
Mixing Decorator Types in a Single Stack
The chain works across decorator types as long as each one sets __wrapped__. A stack can include basic decorators, parameterized decorators, class-based decorators, and third-party decorators, all on the same function, and the __wrapped__ chain will be intact if each layer follows the rules.
import functools
import inspect
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
def log(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"LOG: {func.__name__}")
return func(*args, **kwargs)
return wrapper
def retry(attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i in range(attempts):
try:
return func(*args, **kwargs)
except Exception:
if i == attempts - 1:
raise
return wrapper
return decorator
@CountCalls # class-based
@log # basic function
@retry(attempts=2) # parameterized
def send_email(to, subject, body=""):
"""Send an email to the specified recipient."""
return f"Sent to {to}: {subject}"
# All metadata is correct despite three different decorator types
print(send_email.__name__) # send_email
print(send_email.__doc__) # Send an email to the specified recipient.
print(inspect.signature(send_email)) # (to, subject, body='')
# The chain is intact
print(inspect.unwrap(send_email).__name__) # send_email
@functools.wraps decorator syntax. What do you use instead?Diagnosing and Fixing Broken Chains
When metadata is wrong on a function with multiple decorators, the problem is always the same: one decorator in the stack did not use functools.wraps. The diagnostic process is to walk the __wrapped__ chain manually and find where it breaks.
def diagnose_chain(func):
"""Walk the __wrapped__ chain and report each link."""
depth = 0
current = func
while True:
has_wrapped = hasattr(current, "__wrapped__")
print(
f" [{depth}] __name__={current.__name__!r} "
f"has __wrapped__={has_wrapped}"
)
if not has_wrapped:
break
current = current.__wrapped__
depth += 1
print(f" Chain depth: {depth}")
return depth
print("Diagnosing send_email:")
diagnose_chain(send_email)
# [0] __name__='send_email' has __wrapped__=True
# [1] __name__='send_email' has __wrapped__=True
# [2] __name__='send_email' has __wrapped__=True
# [3] __name__='send_email' has __wrapped__=False
# Chain depth: 3
If a decorator in the chain is missing functools.wraps, the output would show a different __name__ at the broken link (such as 'wrapper' or 'decorator') and has __wrapped__=False before reaching the original function. The depth at which the break occurs tells you which decorator in the stack is the problem.
Third-party decorators that you do not control may not use functools.wraps. If you find a broken chain link at a third-party decorator, you can fix it by wrapping the third-party decorator in your own decorator that applies functools.wraps, or by filing a bug report with the library maintainer. A decorator that does not preserve metadata is a bug, not a design choice.
Extending WRAPPER_ASSIGNMENTS
By default, functools.wraps copies the attributes listed in functools.WRAPPER_ASSIGNMENTS: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. If your decorated functions carry custom attributes or you need default values preserved, you can extend this tuple.
import functools
EXTENDED = functools.WRAPPER_ASSIGNMENTS + (
'__defaults__',
'__kwdefaults__',
)
def preserve_all(func):
@functools.wraps(func, assigned=EXTENDED)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@preserve_all
def connect(host, port=5432, *, ssl=True, timeout=30):
"""Establish a database connection."""
return f"{host}:{port} ssl={ssl} timeout={timeout}"
# __defaults__ and __kwdefaults__ are now available on the wrapper
print(connect.__defaults__) # (5432,)
print(connect.__kwdefaults__) # {'ssl': True, 'timeout': 30}
Without the extended assigned tuple, accessing connect.__defaults__ would return None because the wrapper function itself has no defaults (it accepts *args, **kwargs). With the extension, the original function's default values are accessible on the decorated version, which is important for tools that read defaults programmatically rather than through inspect.signature().
If you are writing decorators for a project that consistently needs extended assignments, define a project-wide wraps function: wraps = functools.partial(functools.wraps, assigned=EXTENDED). Every decorator in the project can then import and use this custom wraps instead of the standard one.
@functools.wraps(func) with default settings. You access decorated_func.__defaults__. What do you get?Key Takeaways
- Every decorator in a stack must use
functools.wraps. Each one copies the original function's__name__,__doc__, and other attributes, and each one sets__wrapped__pointing to the function it received. If a single decorator in the chain omitswraps, the chain breaks at that point andinspect.signature()stops there. - The
__wrapped__chain is a linked list from outermost wrapper to original function. Each link points to the function the decorator received.inspect.unwrap()traverses the chain to return the original function.inspect.signature()usesunwrapinternally to report the correct parameter list. - Place
@functools.wraps(func)on the function object that replaces the original. In basic decorators, that is thewrapperfunction. In three-level parameterized decorators, it is the innermostwrapper. In class-based decorators, callfunctools.update_wrapper(self, func)in__init__. - Diagnose broken chains by walking
__wrapped__manually. Check each link's__name__and whether__wrapped__exists. The first link where__name__is wrong or__wrapped__is missing identifies the broken decorator. Fix it by adding@functools.wraps(func)to its wrapper. - Extend
WRAPPER_ASSIGNMENTSwhen the defaults need to include additional attributes. Passassigned=functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__')tofunctools.wrapsto propagate default parameter values. For project-wide consistency, define a customwrapsusingfunctools.partial.
Metadata preservation in decorator stacks is not inherently complex. It is a mechanical process: each decorator copies the same attributes and sets the same __wrapped__ reference. The difficulty comes from inconsistency. When every decorator in a codebase follows the same pattern, the __wrapped__ chain forms automatically, inspect.signature() reports the correct parameter list, help() shows the right name and docstring, and inspect.unwrap() gives you direct access to the original function. The rule is simple: use functools.wraps in every decorator, in every stack, without exception.
Frequently Asked Questions
Does functools.wraps work correctly when multiple decorators are stacked?
Yes, as long as every decorator in the stack uses @functools.wraps(func). Each decorator copies the original function's __name__, __doc__, and other attributes onto its wrapper, and each sets a __wrapped__ attribute pointing to the function it received. The result is a chain of __wrapped__ references that leads from the outermost wrapper back to the original function. inspect.signature() and help() follow this chain automatically.
How does the __wrapped__ chain work across multiple decorators?
When three decorators A, B, and C are stacked on a function f (with A outermost), the __wrapped__ chain is: f.__wrapped__ points to B's wrapper, B's wrapper.__wrapped__ points to C's wrapper, and C's wrapper.__wrapped__ points to the original function. inspect.unwrap() traverses this entire chain and returns the original function at the end.
What does inspect.unwrap() do?
inspect.unwrap() follows the __wrapped__ chain from a decorated function back to the original, unwrapped function. It returns the last object in the chain that does not have a __wrapped__ attribute. It also accepts an optional stop argument, a callback that can halt unwrapping early. inspect.signature() uses inspect.unwrap() internally to find the original function's parameter list.
What happens if one decorator in a stack does not use functools.wraps?
The __wrapped__ chain breaks at that decorator. inspect.signature() will report the signature of the wrapper that lacks __wrapped__ rather than the original function's signature. The __name__ and __doc__ attributes will also reflect the broken decorator's wrapper instead of the original function. All decorators in a stack must use functools.wraps for metadata to propagate correctly.
Can I extend the set of attributes that functools.wraps copies?
Yes. functools.wraps accepts an assigned parameter that defaults to functools.WRAPPER_ASSIGNMENTS (__module__, __name__, __qualname__, __annotations__, __type_params__, __doc__). You can extend this tuple to include additional attributes like __defaults__ or __kwdefaults__ by passing assigned=functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__').