How to Use functools.wraps to Preserve Metadata in Complex Stacks

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.

Note

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.

Python Pop Quiz
Given three decorators stacked as @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.

Python Pop Quiz
In a parameterized decorator with three levels (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
Python Pop Quiz
In a class-based decorator, you cannot use the @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.

Warning

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().

Pro Tip

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.

Python Pop Quiz
A decorator uses @functools.wraps(func) with default settings. You access decorated_func.__defaults__. What do you get?

Key Takeaways

  1. 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 omits wraps, the chain breaks at that point and inspect.signature() stops there.
  2. 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() uses unwrap internally to report the correct parameter list.
  3. Place @functools.wraps(func) on the function object that replaces the original. In basic decorators, that is the wrapper function. In three-level parameterized decorators, it is the innermost wrapper. In class-based decorators, call functools.update_wrapper(self, func) in __init__.
  4. 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.
  5. Extend WRAPPER_ASSIGNMENTS when the defaults need to include additional attributes. Pass assigned=functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__') to functools.wraps to propagate default parameter values. For project-wide consistency, define a custom wraps using functools.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__').