Skip to main content

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__, __qualname__, __module__, __doc__, __annotations__, and (since Python 3.12) __type_params__, while also merging the original function's __dict__ into the wrapper. It does not copy __defaults__ or __kwdefaults__ — default values are accessible at runtime through the __wrapped__ chain that @functools.wraps also sets. 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:

javascript
# 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.

What functools.wraps actually copies

When you call @functools.wraps(func), it invokes functools.update_wrapper(wrapper, func) internally. That function performs two operations, as defined by two module-level constants in functools.py:

WRAPPER_ASSIGNMENTS — attributes copied directly from the original function onto the wrapper. As of Python 3.12, the full tuple is: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. The __type_params__ attribute was added in Python 3.12 to support PEP 695 generic type parameter syntax. Note that __defaults__ and __kwdefaults__ are not part of WRAPPER_ASSIGNMENTS — default argument values are not copied, which means inspect.signature() resolves correct default values by following the __wrapped__ chain rather than reading the wrapper's own defaults.

WRAPPER_UPDATES — attributes merged (not replaced) from the original into the wrapper: __dict__. This means any attributes already on the wrapper are preserved and the original's attributes are merged in.

Additionally, update_wrapper always sets __wrapped__ on the wrapper to point at the original function. This attribute was added in Python 3.2. inspect.signature() follows __wrapped__ chains by default when resolving parameter signatures.

A less commonly documented behavior: __wrapped__ chains can be traversed manually using inspect.unwrap(func), which follows the chain until it reaches a function with no __wrapped__ attribute. This lets you recover the original unwrapped callable even through multiple layers of decoration, as long as every decorator in the chain uses @functools.wraps. The Python documentation notes that inspect.signature() follows __wrapped__ links by default — if you pass follow_wrapped=False, it will reflect the wrapper's own signature instead.

"Without the use of this decorator factory, the name of the example function would have been 'wrapper', and the docstring of the original example() would have been lost." — Python standard library documentation, functools.wraps

The official functools documentation makes the point plainly: without @functools.wraps, every decorated function silently impersonates the wrapper instead of the original.

The complete list of these constants is in the Python standard library docs for functools.update_wrapper. Both wraps() and update_wrapper() accept optional assigned and updated tuple arguments if you need to customize which attributes are copied.

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:

javascript
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:

python
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:

javascript
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__:

javascript
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:

javascript
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.

Static Typing Limitation

@functools.wraps preserves runtime metadata, but it does not preserve type information for static analysis tools like mypy or pyright. Because the wrapper uses *args, **kwargs, type checkers see those generic parameter types — not the original function's typed signature. This means type checkers may miss argument errors when calling the decorated function. This is a known limitation of functools.wraps. The solution is to combine it with ParamSpec from the typing module — see the Type-Safe Decoration with ParamSpec section below. If you prefer a drop-in replacement instead, the third-party tightwrap package applies functools.wraps under the hood while also working correctly with static analysis tools.

Making the Static Typing Limitation Go Away: ParamSpec

The typing limitation mentioned above has a solution built into the standard library since Python 3.10: ParamSpec from the typing module (PEP 612). Combining @functools.wraps with ParamSpec and TypeVar gives you runtime metadata preservation and static analysis accuracy at the same time.

ParamSpec is a special type variable that captures a function's entire parameter specification — not just the types of individual arguments, but the full signature including positional and keyword parameter structure. When you type your decorator as Callable[P, R] -> Callable[P, R], type checkers like mypy and pyright know that the returned function has the same call signature as the input function. This is the piece that @functools.wraps alone cannot provide.

python
import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")   # Captures the full parameter specification
R = TypeVar("R")     # Captures the return type

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        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

# Type checkers now know: add(a: int, b: int) -> int
# This will be flagged as a type error by mypy/pyright:
# add("wrong", "types")

The key difference in the wrapper signature is *args: P.args, **kwargs: P.kwargs instead of the plain *args, **kwargs. Using P.args and P.kwargs tells the type checker to treat those parameters as having exactly the types captured from the original function. This is how the type information threads through the decorator layer.

On Python 3.12 and later, PEP 695 introduced an even more compact inline syntax. You can declare the parameter spec and return type directly in the function signature using square brackets, eliminating the need for the separate ParamSpec and TypeVar declarations:

python
# Python 3.12+ inline type parameter syntax (PEP 695)
# No separate ParamSpec("P") or TypeVar("R") declarations needed

import functools
from typing import Callable

def log_calls[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
When to Use ParamSpec

If your codebase runs mypy or pyright in strict mode, or if the decorated functions are part of a public API where callers depend on IDE autocompletion for parameter names, combine @functools.wraps with ParamSpec. For internal utility decorators where type accuracy is less critical, plain @functools.wraps is sufficient and simpler.

There is a subtle but important constraint: P.args and P.kwargs must always appear together in the wrapper signature. You cannot use P.args without P.kwargs. Splitting them causes a type error at check time, even though Python itself would not raise an exception at runtime. The Python typing specification treats them as an inseparable pair that represents the full parameter set.

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:

python
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:

javascript
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)

python
# WRONG -- silently produces wrong behavior in Python 3 (no exception raised)
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, Python applies functools.wraps itself as a decorator. Because wraps is implemented as partial(update_wrapper, wrapped=wrapped, ...), writing @functools.wraps calls wraps(wrapper), which treats wrapper as the wrapped argument. The result is update_wrapper(wrapper, wrapped=wrapper) — the wrapper copies its own metadata onto itself. No exception is raised in Python 3, but the original function's attributes are never transferred. The decorated function still carries the wrapper's name and docstring instead of the original's, which is the exact problem @functools.wraps is supposed to solve.

Placed on the wrong layer in a parameterized decorator

python
# 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

javascript
# 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:

javascript
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.

Deeper Solutions and Production Patterns

The standard decorator template handles typical cases, but production codebases surface requirements that the basic approach does not address. The patterns below represent solutions that go beyond placement — they address structural problems that arise at scale.

Selective attribute copying when the wrapper changes the return type

The default WRAPPER_ASSIGNMENTS tuple includes __annotations__. If your decorator intentionally changes the return type — for example, a caching decorator that returns a typed CacheResult wrapper instead of the original return value — inheriting __annotations__ from the original function is misleading. Runtime introspection and documentation generators will display the wrong return type. The solution is to exclude __annotations__ from the copy using the assigned parameter:

python
import functools
from dataclasses import dataclass
from typing import Any

@dataclass
class CacheResult:
    value: Any
    hit: bool

# Exclude __annotations__ -- our wrapper has a different return type
_ASSIGNMENTS_NO_ANNOTATIONS = tuple(
    a for a in functools.WRAPPER_ASSIGNMENTS if a != '__annotations__'
)

def cache_with_result(func):
    @functools.wraps(func, assigned=_ASSIGNMENTS_NO_ANNOTATIONS)
    def wrapper(*args, **kwargs):
        # ... cache lookup logic ...
        return CacheResult(value=func(*args, **kwargs), hit=False)
    wrapper.__annotations__ = {'return': CacheResult}   # set the correct annotation
    return wrapper

@cache_with_result
def compute(x: int) -> int:
    """Compute a value."""
    return x * x

print(compute.__name__)                    # compute
print(compute.__annotations__)             # {'return': <class 'CacheResult'>}
print(compute.__doc__)                     # Compute a value.

This is more precise than either blindly inheriting misleading annotations or stripping them entirely. The decorator communicates an accurate type contract at runtime.

Restoring metadata broken by third-party decorators

A third-party decorator in your stack that does not use @functools.wraps will break the metadata chain at that layer, and you cannot fix it from inside the third-party source. The practical solution is to restore metadata after the fact using functools.update_wrapper directly against the original function:

python
import functools

# Assume some_decorator does not use @functools.wraps internally
from third_party_lib import some_decorator

def apply_with_metadata(decorator, func):
    """Apply a decorator and restore metadata regardless of whether
    the decorator uses @wraps internally."""
    decorated = decorator(func)
    functools.update_wrapper(decorated, func)
    return decorated

def process(x: int) -> int:
    """Process a value."""
    return x * 2

process = apply_with_metadata(some_decorator, process)
print(process.__name__)   # process
print(process.__doc__)    # Process a value.

This pattern is useful when upgrading a third-party dependency is not yet feasible but correct introspection is required for documentation generation, logging, or test tooling.

Building a decorator factory that enforces @wraps by construction

When a team writes many decorators, inconsistent @wraps usage is a maintenance risk. A decorator factory that enforces correct metadata handling by construction eliminates the entire class of placement mistakes — developers supply only the wrapper logic, and the factory handles the boilerplate:

python
import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def make_decorator(pre=None, post=None):
    """
    Factory that produces a decorator from pre-call and post-call hooks.
    @wraps, *args/**kwargs, and ParamSpec handling are enforced automatically.
    """
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            if pre:
                pre(func, args, kwargs)
            result = func(*args, **kwargs)
            if post:
                post(func, result)
            return result
        return wrapper
    return decorator

# Usage: no @wraps boilerplate needed at the call site
log_entry = make_decorator(pre=lambda f, a, k: print(f"entering {f.__name__}"))
count_exit = make_decorator(post=lambda f, r: print(f"{f.__name__} returned"))

@log_entry
@count_exit
def fetch(url: str, timeout: int = 30) -> dict:
    """Fetch data from a URL."""
    return {}

print(fetch.__name__)   # fetch
print(fetch.__doc__)    # Fetch data from a URL.

Walking and auditing the __wrapped__ chain at runtime

In a live application with multiple decorator layers, a chain-walking utility lets you inspect which layers are present and identify any link that is missing @wraps. A layer that was decorated without @wraps will show the wrapper's own name rather than the original function's name:

python
def walk_wrapper_chain(func):
    """
    Walk the __wrapped__ chain and print each layer.
    If a layer is missing __wrapped__, that decorator did not use @functools.wraps.
    """
    layer = 0
    current = func
    while True:
        print(f"  Layer {layer}: {current.__name__!r} ({type(current).__name__})")
        if not hasattr(current, '__wrapped__'):
            print(f"  Chain ends at layer {layer}.")
            break
        current = current.__wrapped__
        layer += 1

# A correctly wrapped chain shows the original name at every layer.
# A layer that shows 'wrapper' or 'inner' means @functools.wraps was omitted there.

Combining this with inspect.unwrap(func) provides a programmatic check: if inspect.unwrap stops earlier than expected, the layer where the chain breaks is the decorator missing @functools.wraps. This is particularly useful in test suites that verify decorator correctness across a large codebase.

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.
  8. On Python 3.12+, __type_params__ is also copied. If you are using PEP 695 type parameter syntax, functools.wraps copies __type_params__ automatically as of Python 3.12. No additional configuration is needed.
  9. @functools.wraps is runtime-only — it does not fix static type checking. Type checkers like mypy and pyright still see the wrapper's *args, **kwargs signature. This is a known limitation of functools.wraps.
  10. Combine with ParamSpec for both runtime and static type accuracy. Using P = ParamSpec("P") and R = TypeVar("R") with a decorator typed as Callable[P, R] -> Callable[P, R], along with *args: P.args, **kwargs: P.kwargs in the wrapper, gives type checkers the full original signature. This requires Python 3.10+ or typing_extensions. On Python 3.12+, the PEP 695 inline syntax def my_decorator[**P, R](func: Callable[P, R]) -> Callable[P, R] eliminates the separate variable declarations entirely.

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.

Check Your Understanding

// click an answer to get feedback — use try again to explore all responses

QUESTION 01
In a simple decorator, where does @functools.wraps(func) go?
Correct. @functools.wraps(func) decorates the wrapper — the function that replaces the original in the namespace. In a simple decorator, that is always the inner function. The outer function is the decorator itself; it receives func but is not replaced by anything.
Not quite. The outer function is the decorator — it receives func as its argument. Placing @wraps on it has no useful effect here. The copy needs to happen on the function that will replace the original, which is the inner wrapper.
No. @functools.wraps is applied per-decorator, not once at the module level. Each decorator that returns a new wrapper function needs its own @functools.wraps(func) placed above that wrapper's def.
QUESTION 02
What happens in Python 3 when you write @functools.wraps without parentheses — no (func) argument?
Not in Python 3. This is the deceptive part of the bug — no exception is raised at decoration time. The decorator silently runs in a way that produces wrong behavior. That silence is exactly what makes the missing-parentheses mistake hard to catch.
No. The decorated function is callable and returns results without error. The bug is purely in the metadata — __name__ and __doc__ reflect the wrapper, not the original. This is a silent correctness failure, not a runtime exception.
Correct. Because functools.wraps is implemented as partial(update_wrapper, ...), writing @functools.wraps without parentheses causes wraps(wrapper) to be called, which treats the wrapper itself as the "wrapped" argument. The result is update_wrapper(wrapper, wrapped=wrapper) — metadata is copied from the wrapper to itself and the original function's identity is never transferred.
QUESTION 03
In a class-based decorator, why can't you use @functools.wraps(func) decorator syntax directly?
Correct. The @decorator syntax applies a decorator to a function definition. In a class-based decorator there is no inner wrapper function — the class instance itself is the callable replacement. functools.update_wrapper(self, func) is exactly what @functools.wraps(func) calls internally; using it directly in __init__ applies that same metadata copy to the instance object.
This is not accurate. Class-based decorators can fully preserve metadata — they just use the explicit functools.update_wrapper(self, func) call in __init__ rather than the @wraps decorator syntax. The result is identical: __name__, __doc__, __wrapped__, and the rest are all transferred.
No. __call__ runs every time the decorated function is called — running update_wrapper there would redundantly re-copy metadata on each invocation. The copy needs to happen once, at decoration time, which is when __init__ runs.

Spot the Bug

// each snippet has exactly one mistake — identify it

BUG HUNT 01

This decorator compiles and runs without an exception. Identify the bug.

import functools def audit_call(func): @functools.wraps def wrapper(*args, **kwargs): print(f"audit: {func.__name__}") return func(*args, **kwargs) return wrapper @audit_call def process(data: list) -> int: """Count items in data.""" return len(data) print(process.__name__) # prints: wrapper <-- wrong print(process.__doc__) # prints: None <-- wrong
No. The function being decorated is a regular synchronous function, so def wrapper is correct. Changing to async def would cause a RuntimeWarning (unawaited coroutine) every time process is called and would return a coroutine object instead of an integer.
That is the bug. @functools.wraps without parentheses applies wraps itself as a decorator, which calls wraps(wrapper) — treating the wrapper as its own "wrapped" source. No exception is raised, but the original function's name and docstring are never copied. The fix is @functools.wraps(func).
No. The print placement is a matter of style preference; placing it before the call is valid for pre-call auditing. The actual bug is in the @functools.wraps line, which is silently operating on the wrong argument.
BUG HUNT 02

A parameterized decorator with three nesting layers. One line is in the wrong place. Find it.

import functools def repeat(n): @functools.wraps(func) def decorator(func): def wrapper(*args, **kwargs): for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def greet(name: str) -> None: """Print a greeting.""" print(f"Hello, {name}")
No. range(n) and range(1, n + 1) both iterate exactly n times. The loop index is never used, so the choice between them has no effect on behavior. The real problem is a scoping error on a different line.
No. Using *args, **kwargs in the wrapper is the standard pattern regardless of the original function's signature. It allows the decorator to work generically across any function without tying its signature to a specific parameter set. The bug is elsewhere.
Correct. @functools.wraps(func) on line 4 references func before it is defined — func only enters scope as the parameter of decorator on the next line. This raises a NameError: name 'func' is not defined at decoration time. The fix is to move @functools.wraps(func) to the line directly above def wrapper, inside decorator, where func is already in scope.

Frequently Asked Questions

// click a question to expand the answer

@functools.wraps(func) goes directly above the def line of the innermost function that will replace the original function in the namespace. In a simple decorator, that is the wrapper function. In a parameterized decorator with three nesting layers, it is still the innermost wrapper. The argument to @wraps is always the function parameter received by the layer directly above the wrapper.

Yes. You need to import functools from the standard library. The two common import styles are import functools (then use @functools.wraps(func)) or from functools import wraps (then use @wraps(func)). Both are equivalent — choose whichever matches your project's import conventions.

Not directly as the @wraps decorator syntax, because there is no standalone wrapper function to decorate. Instead, call functools.update_wrapper(self, func) inside the __init__ method. update_wrapper is the function that @wraps calls internally, and it works the same way when applied to a class instance.

Writing @functools.wraps without parentheses does not raise an exception in Python 3, but it produces silently wrong behavior. Because functools.wraps is implemented as partial(update_wrapper, wrapped=wrapped, ...), applying it without parentheses calls wraps(wrapper), which treats the wrapper function itself as the wrapped argument. The result is update_wrapper(wrapper, wrapped=wrapper) — the wrapper copies its own metadata onto itself. The original function's name and docstring are never transferred, which is the exact problem @functools.wraps is supposed to fix. Always include the parentheses and pass the correct func argument: @functools.wraps(func).

Yes. Any decorator that returns a wrapper function should use @functools.wraps, regardless of what the wrapper does. Even a pass-through decorator that only logs a message still replaces the original function object with the wrapper, and the wrapper will carry wrong metadata without @wraps. The only exception is a decorator that returns the original function unmodified, such as a simple registration decorator.

No. @functools.wraps preserves runtime metadata such as __name__, __doc__, and __annotations__, but it does not make type checkers like mypy or pyright aware of the original function's typed parameter signature. Because the wrapper is defined with *args and **kwargs, type checkers see those generic types and will not catch argument type errors on the decorated function. To get both runtime metadata preservation and static type accuracy, combine @functools.wraps with ParamSpec (typing module, Python 3.10+) and TypeVar. Python 3.12+ supports an inline PEP 695 syntax that eliminates the separate ParamSpec and TypeVar declarations.

Use inspect.unwrap(func), which follows the __wrapped__ chain set by functools.wraps until it reaches a function with no __wrapped__ attribute. Every decorator in the chain must have used @functools.wraps for the chain to be complete. You can also access decorated_function.__wrapped__ directly to get the immediately next layer, rather than the root function.

Yes, directly. Python's help() function reads __name__, __doc__, and __qualname__ from the callable it receives. Without @functools.wraps, calling help(add) on a decorated function displays the wrapper's name and docstring — typically something unhelpful like wrapper(*args, **kwargs) with no documentation. With @functools.wraps, help(add) shows the original function name, its full docstring, and the correct parameter signature, because inspect.signature() follows the __wrapped__ chain that @functools.wraps sets. This makes @functools.wraps essential for any decorator applied to functions that end users or teammates will inspect interactively.

Yes. __annotations__ is part of WRAPPER_ASSIGNMENTS, so it is copied from the original function to the wrapper in full. This includes the return annotation — if the original function is declared as def add(a: int, b: int) -> int, then after decoration add.__annotations__ will contain {'a': int, 'b': int, 'return': int}. However, this is a direct attribute copy, not deep inspection of the signature. As noted in the static typing limitation covered earlier, type checkers like mypy and pyright do not rely on __annotations__ alone to determine the decorated function's callable type — they analyze the wrapper's actual parameter signature, which is still *args, **kwargs. The annotations are present at runtime and visible via inspect.get_annotations(), but they do not give type checkers accurate call-site checking. Only combining @functools.wraps with ParamSpec achieves that.

Yes. Both functools.wraps() and functools.update_wrapper() accept two optional keyword arguments that override their defaults: assigned and updated. The assigned parameter controls which attributes are directly copied from the original function onto the wrapper, defaulting to functools.WRAPPER_ASSIGNMENTS (the tuple containing __module__, __name__, __qualname__, __annotations__, __doc__, and on Python 3.12+ __type_params__). The updated parameter controls which attributes are merged rather than replaced, defaulting to functools.WRAPPER_UPDATES (the tuple containing __dict__). For example, if you want to copy only __name__ and __doc__ and skip everything else, you can write @functools.wraps(func, assigned=('__name__', '__doc__')). One practical reason to restrict assigned is when the original function has annotations that would be misleading on the wrapper — for instance, when the wrapper intentionally changes the return type. In that case, excluding __annotations__ from the copy is more accurate than inheriting annotations that no longer reflect the wrapper's actual behavior.