Python Decorator Losing Original Function Metadata Fix

You write a decorator, apply it to a function, and everything works. Then you check help() and it describes a function called wrapper with no docstring. You check __name__ and it says wrapper. Your logging shows wrapper for every decorated function. Your documentation generator produces blank entries. The function's identity has been silently replaced by the decorator's inner wrapper. This is one of the common problems with Python decorators, and the fix is a single line: @functools.wraps(func).

This article is structured as a troubleshooting guide. It starts with how to diagnose the problem, explains why it happens mechanically, shows the standard fix, walks through verification steps, and then covers every edge case where the straightforward fix needs adjustment: parameterized decorators, stacked chains, class-based decorators, and situations where functools.wraps alone is not sufficient.

Diagnosing the Problem

If you suspect a decorator is losing metadata, run this diagnostic on the decorated function:

import inspect

def diagnose(func):
    """Print metadata for a function to check for decorator damage."""
    print(f"__name__:        {func.__name__}")
    print(f"__qualname__:    {func.__qualname__}")
    print(f"__doc__:         {func.__doc__}")
    print(f"__module__:      {func.__module__}")
    print(f"__annotations__: {func.__annotations__}")
    print(f"__wrapped__:     {hasattr(func, '__wrapped__')}")
    print(f"signature:       {inspect.signature(func)}")
    print()

Apply this to a function before and after decoration. Here is a decorator with the metadata problem:

def timer(func):
    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

@timer
def process_records(records: list, chunk_size: int = 100) -> int:
    """Process records in chunks and return the total count."""
    return len(records)

diagnose(process_records)

The output reveals the damage:

__name__:        wrapper                    # Should be process_records
__qualname__:    timer.<locals>.wrapper      # Should be process_records
__doc__:         None                        # Should be the docstring
__module__:      __main__                    # Correct (by coincidence)
__annotations__: {}                          # Should have type hints
__wrapped__:     False                       # Should be True
signature:       (*args, **kwargs)           # Should show (records, chunk_size=100)

Every line except __module__ is wrong. The function's name, docstring, type annotations, and parameter signature have all been replaced by the wrapper's attributes.

Why Metadata Gets Lost

The @timer syntax is equivalent to process_records = timer(process_records). After this assignment, the name process_records points to the wrapper function object returned by timer. The wrapper has its own __name__ ("wrapper"), its own __doc__ (None), and its own __annotations__ ({}). The original function still exists -- it is captured inside the decorator's closure and accessible via func -- but nothing in the namespace points to it. Python has no automatic mechanism to copy metadata from the wrapped function to the wrapper.

The One-Line Fix

Add @functools.wraps(func) to the wrapper function:

import functools

def timer(func):
    @functools.wraps(func)          # <-- the fix
    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

This single line copies __name__, __doc__, __module__, __qualname__, and __annotations__ from the original function onto the wrapper. It also merges the original function's __dict__ and sets a __wrapped__ attribute pointing back to the original function.

Verifying the Fix

Run the same diagnostic after applying the fix:

@timer
def process_records(records: list, chunk_size: int = 100) -> int:
    """Process records in chunks and return the total count."""
    return len(records)

diagnose(process_records)
__name__:        process_records
__qualname__:    process_records
__doc__:         Process records in chunks and return the total count.
__module__:      __main__
__annotations__: {'records': <class 'list'>, 'chunk_size': <class 'int'>, 'return': <class 'int'>}
__wrapped__:     True
signature:       (records: list, chunk_size: int = 100) -> int

Every attribute now matches the original function. The __wrapped__ attribute exists, which means inspect.signature() followed it to retrieve the original parameter list including names, types, and defaults.

Fixing Parameterized Decorators

Parameterized decorators have three nesting layers. The @functools.wraps(func) must go on the innermost function -- the one that replaces the original in the namespace. Placing it on the middle layer is a common mistake that leaves the metadata unpreserved:

import functools

# WRONG -- @wraps on the middle layer does nothing useful
def retry(max_tries=3):
    @functools.wraps        # This is incorrect
    def decorator(func):
        def wrapper(*args, **kwargs):
            # retry logic
            return func(*args, **kwargs)
        return wrapper
    return decorator

# RIGHT -- @wraps on the innermost layer that replaces the function
def retry(max_tries=3):
    def decorator(func):
        @functools.wraps(func)    # Correct placement
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_tries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == max_tries:
                        raise
        return wrapper
    return decorator
Pro Tip

The rule is straightforward: whichever function gets returned to the namespace as the replacement needs @functools.wraps(func). In a standard decorator that is the wrapper. In a parameterized decorator it is still the wrapper, not the decorator.

Fixing Stacked Decorator Chains

When multiple decorators are stacked, metadata propagation is only as strong as the weakest link. If even one decorator in the chain does not use functools.wraps, the metadata breaks for every decorator above it:

import functools

def good_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def broken_decorator(func):
    def wrapper(*args, **kwargs):     # Missing @functools.wraps
        return func(*args, **kwargs)
    return wrapper

@good_decorator       # Copies metadata from what it receives...
@broken_decorator     # ...but broken_decorator already lost the metadata
def calculate(x, y):
    """Multiply two numbers."""
    return x * y

print(calculate.__name__)   # wrapper (broken_decorator destroyed the metadata)
print(calculate.__doc__)    # None

The good_decorator at the top faithfully copies metadata from its input -- but its input is already the anonymous wrapper from broken_decorator. The fix is to ensure every decorator in the chain uses functools.wraps. If you control the broken decorator, add the line. If it is from a third-party library, consider wrapping it with your own decorator that applies the fix, or filing a bug report.

Fixing Class-Based Decorators

Some decorators use a class with a __call__ method instead of a nested function. The same metadata problem applies -- the class instance replaces the function, and the class does not carry the function's metadata by default. Use functools.update_wrapper in the __init__ method:

import functools

class CountCalls:
    """Decorator that counts how many times a function is called."""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} has been called {self.count} time(s)")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(name: str) -> str:
    """Return a greeting for the given name."""
    return f"Hello, {name}"

print(say_hello.__name__)   # say_hello
print(say_hello.__doc__)    # Return a greeting for the given name.
say_hello("Alice")          # say_hello has been called 1 time(s)

The key difference from function-based decorators: you cannot use @functools.wraps as a decorator on __call__ because __call__ is a method, not a standalone function being returned. Instead, call functools.update_wrapper(self, func) inside __init__ to copy the metadata onto the class instance.

When the Fix Is Not Enough

inspect.getfullargspec Still Shows (*args, **kwargs)

The older inspect.getfullargspec() function does not follow __wrapped__ the way inspect.signature() does. If your code or a tool uses getfullargspec, it will see the wrapper's generic signature even with functools.wraps applied. The solution is to use inspect.signature() instead, which has been the recommended approach since Python 3.3.

Third-Party Decorators Without @wraps

If a third-party decorator you cannot modify does not use functools.wraps, you can create a thin wrapper that re-applies the metadata:

import functools

def fix_metadata(broken_decorator):
    """Wrap a decorator that doesn't preserve metadata."""
    @functools.wraps(broken_decorator)
    def fixed_decorator(func):
        decorated = broken_decorator(func)
        functools.update_wrapper(decorated, func)
        return decorated
    return fixed_decorator

# Usage: wrap the broken decorator before applying it
@fix_metadata(some_broken_decorator)
def my_function():
    """This docstring will survive."""
    pass

Custom Attributes Not Carried Over

If you attach custom attributes to a function (like func.is_admin_only = True) and a decorator replaces the function with a wrapper, those attributes are lost even with functools.wraps -- unless they were set before decoration. Attributes set before decoration are captured in __dict__, which functools.wraps merges. Attributes set after decoration need to be set on the wrapper directly.

Quick Diagnostic Checklist

Symptom Cause Fix
__name__ shows wrapper Decorator missing @functools.wraps Add @functools.wraps(func) to the wrapper
__doc__ is None Wrapper has no docstring and @wraps is missing Add @functools.wraps(func) to the wrapper
inspect.signature shows (*args, **kwargs) No __wrapped__ attribute for inspect.signature to follow Add @functools.wraps(func) (sets __wrapped__)
help() describes wrong function __name__, __doc__, and signature all belong to wrapper Add @functools.wraps(func) to the wrapper
Logging shows wrapper for every function External logging reads __name__ from the decorated object Add @functools.wraps(func) to the wrapper
Metadata fixed for one decorator but lost in a stack Another decorator in the chain is missing @wraps Add @functools.wraps to every decorator in the chain
@wraps applied but on wrong layer Applied to the middle layer of a parameterized decorator Move @functools.wraps(func) to the innermost wrapper
Class-based decorator losing metadata Class instance replaces function without metadata copy Call functools.update_wrapper(self, func) in __init__

Key Takeaways

  1. The problem: decorators silently erase function identity. Without intervention, a decorated function's __name__, __doc__, __annotations__, __qualname__, and effective signature are all replaced by the wrapper's attributes. This breaks help(), logging, documentation, stack traces, and framework registration.
  2. The fix is one line: @functools.wraps(func). Apply it to the wrapper function inside every decorator you write. It copies the five core metadata attributes, merges __dict__, and sets __wrapped__ for signature introspection.
  3. Use the diagnose() function to verify. Printing __name__, __doc__, __annotations__, hasattr(__wrapped__), and inspect.signature() immediately reveals whether metadata was preserved or lost.
  4. In parameterized decorators, @wraps goes on the innermost function. The wrapper that replaces the original in the namespace is always the innermost layer. Placing @wraps on the middle decorator layer does not fix the problem.
  5. In stacked chains, every decorator needs @wraps. One decorator without it breaks the chain for all decorators above. If a third-party decorator is the weak link, wrap it with the fix_metadata helper shown in this article.
  6. Class-based decorators use functools.update_wrapper(self, func). The @functools.wraps decorator cannot be applied to __call__ in the same way. Instead, call update_wrapper inside __init__ to copy metadata onto the class instance.
  7. Prefer inspect.signature() over inspect.getfullargspec(). Only inspect.signature() follows the __wrapped__ chain to resolve the original function's parameter list. The older getfullargspec does not.

Metadata loss from decorators is one of those problems that stays invisible until something that depends on the metadata fails. A logging system shows wrapper for every call. A documentation generator produces blank pages. A testing framework cannot find a function by name. The fix is trivial -- one line, one import -- but it needs to be applied consistently to every decorator in every chain. The diagnose() function at the beginning of this article gives you a quick way to verify that every decorator in your codebase is getting it right.