Navigation

Python

Python functools.wraps: Preserving Function Metadata in Decorators

Master Python decorators with functools.wraps to preserve function metadata, improve debugging, and maintain clean code. Essential for professional development.

When creating decorators in Python, one of the most common issues developers encounter is the loss of important function metadata like the function name, docstring, and other attributes. The functools.wraps decorator solves this problem elegantly by preserving the original function's metadata in the decorated function. This comprehensive guide will show you why this matters and how to use functools.wraps effectively in your decorators.

Table Of Contents

The Problem: Lost Function Metadata

When you create a decorator without using functools.wraps, the decorated function loses its original metadata:

import time

def timing_decorator(func):
    """A decorator that measures execution time."""
    def wrapper(*args, **kwargs):
        """Wrapper function that adds timing functionality."""
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function executed in {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@timing_decorator
def calculate_fibonacci(n):
    """Calculate the nth Fibonacci number recursively."""
    if n <= 1:
        return n
    return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)

# Check the function metadata
print(f"Function name: {calculate_fibonacci.__name__}")
print(f"Function docstring: {calculate_fibonacci.__doc__}")
print(f"Function module: {calculate_fibonacci.__module__}")

# Output:
# Function name: wrapper
# Function docstring: Wrapper function that adds timing functionality.
# Function module: __main__

As you can see, the decorated function has lost its original name and docstring, making debugging and introspection difficult.

The Solution: functools.wraps

functools.wraps is a decorator specifically designed to solve this problem:

import time
from functools import wraps

def timing_decorator(func):
    """A decorator that measures execution time."""
    @wraps(func)  # This preserves the original function's metadata
    def wrapper(*args, **kwargs):
        """Wrapper function that adds timing functionality."""
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function executed in {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@timing_decorator
def calculate_fibonacci(n):
    """Calculate the nth Fibonacci number recursively."""
    if n <= 1:
        return n
    return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)

# Check the function metadata - now preserved!
print(f"Function name: {calculate_fibonacci.__name__}")
print(f"Function docstring: {calculate_fibonacci.__doc__}")
print(f"Function module: {calculate_fibonacci.__module__}")

# Output:
# Function name: calculate_fibonacci
# Function docstring: Calculate the nth Fibonacci number recursively.
# Function module: __main__

What functools.wraps Actually Does

functools.wraps is itself a decorator that copies several important attributes from the original function to the wrapper function:

from functools import wraps

def demonstrate_wraps(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@demonstrate_wraps
def example_function():
    """This is an example function."""
    pass

# These attributes are preserved by @wraps:
print("Preserved attributes:")
print(f"__name__: {example_function.__name__}")           # example_function
print(f"__doc__: {example_function.__doc__}")             # This is an example function.
print(f"__module__: {example_function.__module__}")       # __main__
print(f"__qualname__: {example_function.__qualname__}")   # example_function
print(f"__annotations__: {example_function.__annotations__}")  # {}

# The wrapper function is still accessible
print(f"__wrapped__: {example_function.__wrapped__}")     # <function example_function at 0x...>

Essential Patterns with functools.wraps

Basic Decorator Template

from functools import wraps

def my_decorator(func):
    """Template for creating decorators with preserved metadata."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Pre-execution logic
        print(f"Calling {func.__name__}")
        
        # Call the original function
        result = func(*args, **kwargs)
        
        # Post-execution logic
        print(f"Finished {func.__name__}")
        
        return result
    return wrapper

@my_decorator
def greet(name):
    """Greet a person by name."""
    return f"Hello, {name}!"

print(greet("Alice"))
print(f"Function name preserved: {greet.__name__}")

Logging Decorator

import logging
from functools import wraps
from datetime import datetime

def log_calls(level=logging.INFO, include_args=True, include_result=True):
    """Decorator that logs function calls with configurable options."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            logger = logging.getLogger(func.__module__)
            
            # Prepare log message
            func_name = func.__qualname__
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            
            if include_args and (args or kwargs):
                args_str = ', '.join(repr(arg) for arg in args)
                kwargs_str = ', '.join(f"{k}={v!r}" for k, v in kwargs.items())
                all_args = ', '.join(filter(None, [args_str, kwargs_str]))
                log_msg = f"[{timestamp}] Calling {func_name}({all_args})"
            else:
                log_msg = f"[{timestamp}] Calling {func_name}()"
            
            logger.log(level, log_msg)
            
            try:
                result = func(*args, **kwargs)
                
                if include_result:
                    logger.log(level, f"[{timestamp}] {func_name} returned: {result!r}")
                else:
                    logger.log(level, f"[{timestamp}] {func_name} completed successfully")
                
                return result
            
            except Exception as e:
                logger.error(f"[{timestamp}] {func_name} raised {type(e).__name__}: {e}")
                raise
        
        return wrapper
    return decorator

# Configure logging
logging.basicConfig(level=logging.INFO)

@log_calls(level=logging.DEBUG, include_result=False)
def divide(a, b):
    """Divide two numbers."""
    return a / b

@log_calls()
def fetch_user_data(user_id, include_preferences=False):
    """Fetch user data from the database."""
    # Simulate database operation
    return {"id": user_id, "name": "John Doe", "preferences": {} if include_preferences else None}

# Test the decorated functions
result = divide(10, 2)
user_data = fetch_user_data(123, include_preferences=True)

Caching Decorator with Metadata Preservation

from functools import wraps
import time

def cache_with_ttl(ttl_seconds=300):
    """Decorator that caches function results with time-to-live."""
    def decorator(func):
        cache = {}
        
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Create cache key
            key = str(args) + str(sorted(kwargs.items()))
            current_time = time.time()
            
            # Check if result is in cache and not expired
            if key in cache:
                result, timestamp = cache[key]
                if current_time - timestamp < ttl_seconds:
                    print(f"Cache hit for {func.__name__}")
                    return result
                else:
                    print(f"Cache expired for {func.__name__}")
                    del cache[key]
            
            # Call function and cache result
            print(f"Computing {func.__name__}")
            result = func(*args, **kwargs)
            cache[key] = (result, current_time)
            return result
        
        # Add cache management methods
        wrapper.cache_info = lambda: {
            'size': len(cache),
            'keys': list(cache.keys())
        }
        wrapper.cache_clear = lambda: cache.clear()
        
        return wrapper
    return decorator

@cache_with_ttl(ttl_seconds=5)
def expensive_computation(x, y):
    """Perform an expensive mathematical computation."""
    time.sleep(1)  # Simulate expensive operation
    return x ** y + y ** x

# Test caching behavior
print(expensive_computation(2, 3))  # Computed
print(expensive_computation(2, 3))  # Cache hit
time.sleep(6)
print(expensive_computation(2, 3))  # Cache expired, computed again

# Function metadata is preserved
print(f"Function: {expensive_computation.__name__}")
print(f"Docstring: {expensive_computation.__doc__}")
print(f"Cache info: {expensive_computation.cache_info()}")

Advanced Decorator Patterns

Decorator with Arguments and Metadata Preservation

from functools import wraps
import time

def retry(max_attempts=3, delay=1, exceptions=(Exception,)):
    """Decorator that retries function execution on failure."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            
            for attempt in range(max_attempts):
                try:
                    result = func(*args, **kwargs)
                    if attempt > 0:
                        print(f"{func.__name__} succeeded on attempt {attempt + 1}")
                    return result
                
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts - 1:
                        print(f"{func.__name__} failed (attempt {attempt + 1}/{max_attempts}): {e}")
                        print(f"Retrying in {delay} seconds...")
                        time.sleep(delay)
                    else:
                        print(f"{func.__name__} failed after {max_attempts} attempts")
            
            raise last_exception
        
        # Preserve decorator configuration in the wrapped function
        wrapper.max_attempts = max_attempts
        wrapper.delay = delay
        wrapper.exceptions = exceptions
        
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def unreliable_network_call():
    """Simulate an unreliable network call."""
    import random
    if random.random() < 0.7:  # 70% failure rate
        raise ConnectionError("Network is unreliable")
    return "Success!"

# Function metadata and retry configuration are preserved
print(f"Function: {unreliable_network_call.__name__}")
print(f"Max attempts: {unreliable_network_call.max_attempts}")
print(f"Delay: {unreliable_network_call.delay}")

try:
    result = unreliable_network_call()
    print(f"Result: {result}")
except Exception as e:
    print(f"Final failure: {e}")

Class-Based Decorators with wraps

from functools import wraps
import time
import threading

class RateLimiter:
    """Class-based decorator for rate limiting function calls."""
    
    def __init__(self, max_calls=5, time_window=60):
        self.max_calls = max_calls
        self.time_window = time_window
        self.calls = []
        self.lock = threading.Lock()
    
    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with self.lock:
                current_time = time.time()
                
                # Remove old calls outside the time window
                self.calls = [call_time for call_time in self.calls 
                            if current_time - call_time < self.time_window]
                
                # Check rate limit
                if len(self.calls) >= self.max_calls:
                    oldest_call = min(self.calls)
                    wait_time = self.time_window - (current_time - oldest_call)
                    raise Exception(f"Rate limit exceeded. Try again in {wait_time:.1f} seconds")
                
                # Record this call
                self.calls.append(current_time)
            
            return func(*args, **kwargs)
        
        # Preserve rate limiter configuration
        wrapper.max_calls = self.max_calls
        wrapper.time_window = self.time_window
        wrapper.get_remaining_calls = lambda: self.max_calls - len(self.calls)
        
        return wrapper

@RateLimiter(max_calls=3, time_window=10)
def api_call(endpoint):
    """Make an API call to the specified endpoint."""
    print(f"Calling API endpoint: {endpoint}")
    return f"Response from {endpoint}"

# Test rate limiting
for i in range(5):
    try:
        result = api_call(f"/users/{i}")
        print(f"Success: {result}")
        print(f"Remaining calls: {api_call.get_remaining_calls()}")
    except Exception as e:
        print(f"Error: {e}")
        
print(f"Function name preserved: {api_call.__name__}")
print(f"Rate limit config: {api_call.max_calls} calls per {api_call.time_window}s")

Debugging and Introspection Benefits

Debugging Decorated Functions

from functools import wraps
import traceback

def debug_decorator(func):
    """Decorator that helps with debugging by preserving stack traces."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            print(f"DEBUG: Entering {func.__qualname__}")
            print(f"DEBUG: Args: {args}")
            print(f"DEBUG: Kwargs: {kwargs}")
            
            result = func(*args, **kwargs)
            
            print(f"DEBUG: {func.__qualname__} returned: {type(result).__name__}")
            return result
            
        except Exception as e:
            print(f"DEBUG: Exception in {func.__qualname__}: {e}")
            print("DEBUG: Full traceback:")
            traceback.print_exc()
            raise
    
    return wrapper

@debug_decorator
def problematic_function(x, y):
    """A function that might cause problems."""
    if x == 0:
        raise ValueError("x cannot be zero")
    return y / x

# Test debugging
try:
    result = problematic_function(2, 10)
    print(f"Result: {result}")
    
    # This will trigger the debug information
    problematic_function(0, 10)
except ValueError as e:
    print(f"Caught expected error: {e}")

# Function metadata is preserved for better debugging
print(f"Function name: {problematic_function.__name__}")
print(f"Function file: {problematic_function.__code__.co_filename}")
print(f"Function line: {problematic_function.__code__.co_firstlineno}")

Creating Decorator Documentation

from functools import wraps
import inspect

def documented_decorator(description=""):
    """Decorator that automatically documents the decoration."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        
        # Enhance docstring with decorator information
        original_doc = func.__doc__ or ""
        decoration_info = f"\n\nDecorated with: {description}"
        wrapper.__doc__ = original_doc + decoration_info
        
        # Add decorator metadata
        wrapper._decorator_info = {
            'decorator': 'documented_decorator',
            'description': description,
            'original_function': func,
            'decorated_at': inspect.currentframe().f_back.f_lineno
        }
        
        return wrapper
    return decorator

@documented_decorator("Adds automatic retry logic with exponential backoff")
def network_operation():
    """Perform a network operation that might fail."""
    return "Network operation completed"

@documented_decorator("Validates input parameters before execution")
def calculate_area(length, width):
    """Calculate the area of a rectangle."""
    return length * width

# Check enhanced documentation
print("Network operation docstring:")
print(network_operation.__doc__)
print("\nDecorator info:")
print(network_operation._decorator_info)

print("\nCalculate area docstring:")
print(calculate_area.__doc__)

Best Practices and Common Pitfalls

Always Use functools.wraps

from functools import wraps

# ❌ BAD: Without wraps
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# ✅ GOOD: With wraps
def good_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bad_decorator
def function_a():
    """Original docstring A"""
    pass

@good_decorator
def function_b():
    """Original docstring B"""
    pass

print(f"Bad: {function_a.__name__}, {function_a.__doc__}")  # wrapper, None
print(f"Good: {function_b.__name__}, {function_b.__doc__}")  # function_b, Original docstring B

Preserving Type Hints

from functools import wraps
from typing import Callable, TypeVar, Any

F = TypeVar('F', bound=Callable[..., Any])

def preserve_signature(func: F) -> F:
    """Decorator that preserves type hints and signatures."""
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        return func(*args, **kwargs)
    return wrapper  # type: ignore

@preserve_signature
def typed_function(x: int, y: str = "default") -> str:
    """A function with type hints."""
    return f"{x}: {y}"

# Type hints are preserved for static analysis tools
print(f"Annotations: {typed_function.__annotations__}")

Handling Multiple Decorators

from functools import wraps

def decorator_a(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Decorator A: before")
        result = func(*args, **kwargs)
        print("Decorator A: after")
        return result
    return wrapper

def decorator_b(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Decorator B: before")
        result = func(*args, **kwargs)
        print("Decorator B: after")
        return result
    return wrapper

@decorator_a
@decorator_b
def multiple_decorated():
    """Function with multiple decorators."""
    print("Original function")
    return "result"

# Even with multiple decorators, metadata is preserved
print(f"Function name: {multiple_decorated.__name__}")
print(f"Docstring: {multiple_decorated.__doc__}")

# Execute to see decorator order
multiple_decorated()

Conclusion

functools.wraps is essential for creating professional, maintainable decorators in Python. It ensures that:

  • Function metadata is preserved for debugging and introspection
  • Documentation tools work correctly with decorated functions
  • Stack traces remain clear and point to the original function
  • Type hints and signatures are maintained for static analysis
  • IDE support remains functional for decorated functions

Key benefits:

  • Better debugging experience
  • Preserved function identity
  • Maintained documentation
  • Improved developer experience
  • Professional code quality

Best practices:

  • Always use @wraps(func) in decorator implementations
  • Include it even in simple decorators
  • Combine it with proper error handling
  • Document your decorators thoroughly
  • Consider type hints for better static analysis

By consistently using functools.wraps, you'll create decorators that integrate seamlessly with Python's ecosystem while maintaining the clarity and debuggability of your code.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Python