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
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
- 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 breakshelp(), logging, documentation, stack traces, and framework registration. - 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. - Use the
diagnose()function to verify. Printing__name__,__doc__,__annotations__,hasattr(__wrapped__), andinspect.signature()immediately reveals whether metadata was preserved or lost. - In parameterized decorators, @wraps goes on the innermost function. The wrapper that replaces the original in the namespace is always the innermost layer. Placing
@wrapson the middledecoratorlayer does not fix the problem. - 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_metadatahelper shown in this article. - Class-based decorators use
functools.update_wrapper(self, func). The@functools.wrapsdecorator cannot be applied to__call__in the same way. Instead, callupdate_wrapperinside__init__to copy metadata onto the class instance. - Prefer
inspect.signature()overinspect.getfullargspec(). Onlyinspect.signature()follows the__wrapped__chain to resolve the original function's parameter list. The oldergetfullargspecdoes 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.