How to Use @functools.wraps(func) Inside a Decorator

@functools.wraps(func) is a one-line addition to any custom decorator that preserves the original function's name, docstring, type annotations, and signature. Without it, every decorated function loses its identity and presents itself as the inner wrapper. This guide shows you exactly where to place it in every type of decorator you will write -- simple decorators, parameterized decorators, class-based decorators, stacked chains, and async wrappers -- with copy-ready boilerplate for each.

The Import

Before using @functools.wraps, you need to import the module. There are two common styles:

# Style 1: import the module
import functools
# Usage: @functools.wraps(func)

# Style 2: import the function directly
from functools import wraps
# Usage: @wraps(func)

Both are equivalent. This article uses import functools for clarity so that every usage is explicitly namespaced.

Placement in a Simple Decorator

A simple decorator has two layers: an outer function that receives the target function, and an inner wrapper function that replaces it. The @functools.wraps(func) line goes directly above the def line of the wrapper:

import functools

def log_calls(func):                     # Outer: receives the function
    @functools.wraps(func)                # <-- placed here
    def wrapper(*args, **kwargs):         # Inner: replaces the function
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

print(add.__name__)   # add
print(add.__doc__)    # Add two integers.

The rule is mechanical: @functools.wraps(func) goes on whichever function gets returned as the replacement. In a simple decorator, that is always the wrapper.

The Universal Boilerplate

Every simple decorator you write can start from this template. Copy it, rename the decorator, and add your logic in the marked locations:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # YOUR PRE-CALL LOGIC HERE
        result = func(*args, **kwargs)
        # YOUR POST-CALL LOGIC HERE
        return result
    return wrapper
Pro Tip

Save this boilerplate as a snippet in your editor. Every new decorator you write starts from it. The three things that change each time are the decorator name, the pre-call logic, and the post-call logic. The @functools.wraps(func) line and the *args, **kwargs pattern stay the same every time.

Placement in a Parameterized Decorator

A parameterized decorator -- one that accepts its own configuration arguments like @retry(max_tries=3) -- has three nesting layers. The @functools.wraps(func) still goes on the innermost function:

import functools
import time

def retry(max_tries=3, delay=1.0):       # Layer 1: captures config
    def decorator(func):                  # Layer 2: receives the function
        @functools.wraps(func)            # <-- placed here, on layer 3
        def wrapper(*args, **kwargs):     # Layer 3: replaces the function
            last_exc = None
            for attempt in range(1, max_tries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    if attempt < max_tries:
                        time.sleep(delay)
            raise last_exc
        return wrapper
    return decorator

@retry(max_tries=4, delay=0.5)
def fetch_data(url: str) -> dict:
    """Fetch JSON data from the given URL."""
    pass

print(fetch_data.__name__)   # fetch_data
print(fetch_data.__doc__)    # Fetch JSON data from the given URL.

The argument to @functools.wraps is the func parameter from the layer directly above. In a simple decorator, the outer function receives func and the @wraps argument is func. In a parameterized decorator, the middle function receives func and the @wraps argument is still func. The pattern is always: @wraps receives the function parameter from its enclosing scope.

Placement in a Class-Based Decorator

Class-based decorators use __init__ to receive the function and __call__ to run the wrapper logic. Since there is no standalone wrapper function to decorate with @functools.wraps, you call functools.update_wrapper directly inside __init__:

import functools

class CountCalls:
    """Track how many times a function is called."""

    def __init__(self, func):
        functools.update_wrapper(self, func)    # <-- placed here
        self.func = func
        self.count = 0

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

@CountCalls
def greet(name: str) -> str:
    """Return a personalized greeting."""
    return f"Hello, {name}"

print(greet.__name__)   # greet
print(greet.__doc__)    # Return a personalized greeting.

functools.update_wrapper(self, func) does the same work as @functools.wraps(func). In fact, @functools.wraps(func) is just a convenience decorator that calls update_wrapper internally. The class-based form uses the direct call because there is no function definition to decorate.

Placement in an Async Decorator

Async decorators follow the same placement rule as synchronous ones. The only difference is that the wrapper is defined with async def and uses await:

import functools
import asyncio
import time

def async_timer(func):
    @functools.wraps(func)                   # <-- same placement as sync
    async def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = await func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@async_timer
async def fetch_user(user_id: int) -> dict:
    """Fetch user data asynchronously."""
    await asyncio.sleep(0.1)    # Simulating async I/O
    return {"id": user_id, "name": "Alice"}

print(fetch_user.__name__)   # fetch_user
print(fetch_user.__doc__)    # Fetch user data asynchronously.

@functools.wraps(func) works identically on async def functions. It copies the same attributes regardless of whether the wrapper is a coroutine or a regular function.

Placement in Stacked Decorator Chains

When multiple decorators are stacked on the same function, every decorator in the chain needs its own @functools.wraps(func). Each decorator receives a function (which may already be a wrapper from the decorator below) and returns a new wrapper. Each layer must copy metadata from whatever it receives:

import functools

def timer(func):
    @functools.wraps(func)          # Each decorator needs its own @wraps
    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

def log_calls(func):
    @functools.wraps(func)          # Each decorator needs its own @wraps
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
@timer
def multiply(x: int, y: int) -> int:
    """Multiply two integers."""
    return x * y

print(multiply.__name__)   # multiply
print(multiply.__doc__)    # Multiply two integers.

If timer uses @wraps but log_calls does not, the metadata breaks at log_calls. If log_calls uses @wraps but timer does not, the metadata breaks at timer and log_calls copies the broken metadata from timer's wrapper. Every link in the chain must participate for the original metadata to survive to the outermost layer.

Warning

If a third-party decorator in your stack does not use @functools.wraps, it will break metadata propagation for the entire chain above it. You cannot fix this from outside the decorator without replacing or wrapping it.

When You Do Not Need @wraps

There is one category of decorator where @functools.wraps is unnecessary: decorators that return the original function unmodified. A registration decorator, for example, stores a reference to the function and then returns it without wrapping it in a new function:

REGISTRY = {}

def register(func):
    """Register a function in the global registry."""
    REGISTRY[func.__name__] = func
    return func    # Returns the ORIGINAL function, not a wrapper

@register
def process():
    """Process data."""
    pass

print(process.__name__)   # process (no wrapper involved, no @wraps needed)

Because the original function is returned unchanged, there is no wrapper to copy metadata onto. The function's identity is never replaced. This is the only case where @functools.wraps is not needed. If a decorator returns a new function object, it needs @wraps.

Common Placement Mistakes

Missing parentheses: @functools.wraps instead of @functools.wraps(func)

# WRONG -- passes the wrapper to wraps as the "wrapped" argument
def my_decorator(func):
    @functools.wraps          # Missing (func) !
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# RIGHT
def my_decorator(func):
    @functools.wraps(func)    # Correct
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Without the parentheses, functools.wraps receives the wrapper function itself as its argument instead of the original function. This produces incorrect behavior -- the wrapper ends up copying metadata from itself.

Placed on the wrong layer in a parameterized decorator

# WRONG -- @wraps on the middle layer
def repeat(n):
    @functools.wraps            # Wrong layer, and missing (func)
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

# RIGHT -- @wraps on the innermost layer
def repeat(n):
    def decorator(func):
        @functools.wraps(func)  # Correct layer and correct argument
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

Applied to the decorator function instead of the wrapper

# WRONG -- @wraps on the outer function
@functools.wraps                    # This makes no sense here
def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# RIGHT -- @wraps on the wrapper
def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Verifying Correct Placement

After adding @functools.wraps(func) to your decorator, verify with this four-line check:

import inspect

@my_decorator
def sample(x: int, y: int = 10) -> int:
    """A sample function for verification."""
    return x + y

assert sample.__name__ == "sample",          f"Name wrong: {sample.__name__}"
assert sample.__doc__  == "A sample function for verification.", f"Doc wrong: {sample.__doc__}"
assert hasattr(sample, '__wrapped__'),       "__wrapped__ missing"
assert str(inspect.signature(sample)) == "(x: int, y: int = 10) -> int", "Signature wrong"
print("All metadata preserved correctly.")

If any assertion fails, the decorator is not using @functools.wraps correctly. The four checks cover the function name, docstring, __wrapped__ attribute presence, and the complete parameter signature including type annotations and defaults.

Key Takeaways

  1. Import first: import functools. The wraps function lives in the functools standard library module. No installation is needed -- it ships with Python.
  2. Place @functools.wraps(func) directly above the wrapper def line. The wrapper is the innermost function that replaces the original in the namespace. This is true for simple, parameterized, and async decorators alike.
  3. The argument is always the function from the enclosing scope. In a simple decorator, the outer function receives func. In a parameterized decorator, the middle function receives func. The @wraps argument is always that func parameter.
  4. For class-based decorators, call functools.update_wrapper(self, func) in __init__. This is the explicit-call form of @wraps and works the same way on class instances.
  5. Every decorator in a stacked chain needs its own @wraps. Metadata propagation is a chain -- each decorator copies metadata from what it receives, so every link must participate.
  6. Skip @wraps only when returning the original function unmodified. Registration decorators that store a reference and return the function as-is do not create a wrapper, so there is nothing to copy metadata onto.
  7. Verify with four assertions. Check __name__, __doc__, hasattr(__wrapped__), and inspect.signature(). If all four pass, the decorator is correctly preserving metadata.

The placement rule for @functools.wraps(func) is consistent across every decorator variant: it goes on the function that replaces the original. Once that rule is internalized, applying it becomes automatic. Every decorator template in this article -- simple, parameterized, class-based, async, and stacked -- follows the exact same pattern with the exact same result: the decorated function keeps its original identity.