Preserving Function Metadata in Python Decorators with functools.wraps
Learn how to use functools.wraps to preserve function metadata such as __name__ and __doc__ in Python decorators, with practical examples and limitations.
Writing a decorator in Python replaces the original function with a wrapper. Without extra care, the wrapper loses metadata such as __name__, __doc__, annotations, and __module__. This breaks introspection tools, logging, documentation generators, and debugging. The standard solution is to use functools.wraps to copy that metadata onto the wrapper. This article explains why the loss happens, how functools.wraps solves it, and what to consider when applying it in real code.
What Happens Without Preserving Metadata
Consider a simple decorator that logs the execution time of a function:
import time def timed(func): def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed:.4f}s") return result return wrapper
Apply it to a function and inspect the result:
@timed def process_data(): """Processes the data pipeline.""" return [i for i in range(1000)] print(process_data.__name__) # 'wrapper' print(process_data.__doc__) # None
The decorated function is now wrapper, not process_data. The docstring is gone. If you use help(process_data) or a debugger, you see the wrapper's signature and no documentation. This is the core problem: the decorator hides the original function's identity.
The Role of functools.wraps
Python's standard library provides functools.wraps to copy the original function's metadata onto the wrapper. It is a decorator that applies functools.update_wrapper to the wrapper function. The typical pattern is:
import functools import time def timed(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed:.4f}s") return result return wrapper
Now the decorated function retains its name and docstring:
@timed def process_data(): """Processes the data pipeline.""" return [i for i in range(1000)] print(process_data.__name__) # 'process_data' print(process_data.__doc__) # 'Processes the data pipeline.'
functools.wraps copies the __module__, __name__, __qualname__, __doc__, and __annotations__ attributes from the original function to the wrapper. It also updates the wrapper's __dict__ with the original's, so custom attributes set on the original function are preserved.
What Metadata Is Actually Preserved
functools.update_wrapper copies a specific set of attributes. The default WRAPPER_ASSIGNMENTS tuple includes:
__module____name____qualname____annotations____doc__
It also updates the wrapper's __dict__ with the original function's __dict__ (via WRAPPER_UPDATES). This means any custom attributes you set on the original function are also copied.
| Attribute | Copied by default | Purpose |
|---|---|---|
__name__ | Yes | Function name for debugging/logging |
__doc__ | Yes | Docstring for help() and docs |
__annotations__ | Yes | Type hints for introspection |
__module__ | Yes | Module where the function is defined |
__qualname__ | Yes | Qualified name for nested functions |
__dict__ | Yes (updated, not replaced) | Custom attributes |
Note that __signature__ is not in the default assignment list. See the limitations section for how inspect.signature() handles this.
Using wraps with Custom Decorators That Accept Arguments
When a decorator itself takes arguments, you need an extra layer. The pattern is to have a factory function that returns a decorator. functools.wraps still works in the innermost wrapper:
def repeat(times): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def greet(name): """Greets a person.""" return f"Hello, {name}" print(greet.__name__) # 'greet' print(greet.__doc__) # 'Greets a person.'
The wrapper still replaces the original, but its metadata now matches the original. The behavior is correct, and the function appears as the original to external tools.
Preserving Metadata for Class-Based Decorators
Decorators can also be implemented as classes using __call__. In that case, you need to manually call functools.update_wrapper in the __init__ or __call__ method. A common pattern:
class Retry: def __init__(self, func): self.func = func functools.update_wrapper(self, func) def __call__(self, *args, **kwargs): try: return self.func(*args, **kwargs) except Exception: # retry logic return self.func(*args, **kwargs) @Retry def fetch_data(): """Fetches data from an API.""" return {"ok": True} print(fetch_data.__name__) # 'fetch_data' print(fetch_data.__doc__) # 'Fetches data from an API.'
Here, update_wrapper copies the metadata from func to the instance self. Since the instance is callable, it acts as the decorated function. This approach works but requires explicit handling; forgetting update_wrapper leads to the same metadata loss as before.
Edge Cases and Limitations
functools.wraps does not copy the __signature__ attribute. In practice, inspect.signature() normally reports the original signature because it follows __wrapped__; the generic (*args, **kwargs) signature is only visible when a tool inspects the wrapper with follow_wrapped=False or when a decorator does not set __wrapped__. If a tool needs the __signature__ attribute on the wrapper itself, set it manually:
import inspect def preserve_signature(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) wrapper.__signature__ = inspect.signature(func) return wrapper
The third-party decorator library also generates wrappers whose signatures match the original.
Another limitation is that functools.wraps copies the __dict__ by updating, not replacing. If the wrapper already has attributes, they are kept unless the original has the same key. This is usually fine but can lead to unexpected behavior if you set attributes on the wrapper before calling wraps.
Performance overhead is minimal at decoration time: update_wrapper runs once per decorator application, not on every call. The wrapper does add one extra Python function call per invocation, which usually matters only in hot paths. The real cost of omitting wraps is the loss of introspectability, which can cause subtle failures or misleading output in tools that rely on __name__ or __doc__.
Maintainability and Production Considerations
In production, preserving metadata matters for observability. Logging systems often use func.__name__ to identify the source of a log entry. If a decorator strips that name, logs become ambiguous. Documentation tools like Sphinx use docstrings to generate API docs; without wraps, the docs would show the wrapper's docstring (or none). Note that tracebacks can still include the wrapper frame because tracebacks use the underlying code object's name; functools.wraps preserves metadata but does not rename the wrapper's code object.
When you write a decorator that will be reused across a codebase, always apply functools.wraps to the inner wrapper. It costs nothing and prevents subtle bugs. For class-based decorators, call update_wrapper in __init__. For most cases, functools.wraps is sufficient and keeps the code standard-library-only.
A final practical detail: functools.wraps also sets the __wrapped__ attribute on the wrapper to point to the original function. This allows inspect.unwrap() to retrieve the original function, which is useful for deep introspection or for bypassing decorators in tests. You can rely on this attribute to access the underlying implementation when needed.
When you build a decorator that preserves metadata, you make your codebase more maintainable and less surprising for other developers who consume your functions. The few extra characters required by @functools.wraps are a small price for the clarity they provide.