A basic decorator takes one argument: the function it wraps. A parameterized decorator takes configuration arguments—a retry count, a log level, a timeout duration—and returns the actual decorator. This extra layer of nesting means there are three functions involved instead of two, and knowing exactly where to place @functools.wraps becomes the single point of confusion that trips people up. The rule is simple: apply it to the innermost function, the one that replaces the original. The Python documentation describes functools.wraps as a convenience function for applying update_wrapper() when defining a wrapper (Python docs: functools.wraps). This article walks through why the placement matters, shows the common mistakes, and builds several production-ready examples—all verified against the Python 3.14 standard library documentation.
The Three-Layer Structure
A parameterized decorator has three nested functions. Each one has a distinct job:
import functools
def repeat(n): # Layer 1: FACTORY
"""Decorator factory -- receives configuration."""
def decorator(func): # Layer 2: DECORATOR
"""Actual decorator -- receives the function."""
@functools.wraps(func)
def wrapper(*args, **kwargs): # Layer 3: WRAPPER
"""Wrapper -- replaces the original function."""
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(n=3)
def say_hello(name):
"""Greet someone by name."""
print(f"Hello, {name}")
say_hello("Ada")
# Hello, Ada
# Hello, Ada
# Hello, Ada
print(say_hello.__name__) # say_hello
print(say_hello.__doc__) # Greet someone by name.
When Python processes @repeat(n=3), it executes two steps. First, it calls repeat(n=3), which returns decorator. Second, it applies decorator to say_hello, which calls decorator(say_hello), which returns wrapper. The name say_hello is now rebound to wrapper. This is equivalent to writing:
# These two forms are exactly equivalent:
@repeat(n=3)
def say_hello(name):
print(f"Hello, {name}")
# is the same as:
def say_hello(name):
print(f"Hello, {name}")
say_hello = repeat(n=3)(say_hello)
@repeat(n=3) above a function definition, how many function calls happen during decoration?Where @functools.wraps Goes
The rule is: apply @functools.wraps(func) to whichever function replaces the original. In the three-layer pattern, that is always the innermost function—the wrapper. It is the function that decorator returns, and it is the function that the original name gets rebound to.
| Layer | Function Name | Receives | Returns | Gets @wraps? |
|---|---|---|---|---|
| 1 (Factory) | repeat(n) |
Configuration arguments | decorator |
No |
| 2 (Decorator) | decorator(func) |
The original function | wrapper |
No |
| 3 (Wrapper) | wrapper(*args, **kwargs) |
The call arguments | The function's return value | Yes |
The factory never sees the original function—it only sees configuration values. The decorator sees the original function, but it is not the one that replaces it. The wrapper is the one that takes over the original name, so it is the one that needs the original's metadata.
What @functools.wraps Copies
When @functools.wraps(func) is applied, it copies six attributes from the original function to the wrapper. These are defined by the module-level constant functools.WRAPPER_ASSIGNMENTS: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. It also updates the wrapper's __dict__ with entries from the original (via WRAPPER_UPDATES), and automatically sets a __wrapped__ attribute pointing to the original function. The __type_params__ attribute was added in Python 3.12, and the automatic __wrapped__ attribute has been present since Python 3.2 (Python docs: functools.update_wrapper).
How inspect.signature() Uses __wrapped__
One of the practical reasons @functools.wraps matters beyond just __name__ and __doc__ is that inspect.signature() follows the __wrapped__ attribute to report the original function's parameter signature rather than the wrapper's generic (*args, **kwargs). This behavior was introduced in Python 3.5, and it means that tools like help(), debuggers, and IDE autocompletion see the correct parameter names and defaults when your decorator uses @functools.wraps. If you need the wrapper's own signature instead, pass follow_wrapped=False to inspect.signature() (Python docs: inspect.signature).
import functools
import inspect
def repeat(n):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(n=3)
def say_hello(name: str, greeting: str = "Hello") -> None:
"""Greet someone by name."""
print(f"{greeting}, {name}")
# inspect.signature() follows __wrapped__ to show the real signature:
print(inspect.signature(say_hello)) # (name: str, greeting: str = 'Hello') -> None
# The wrapper's own signature is generic:
print(inspect.signature(say_hello, follow_wrapped=False)) # (*args, **kwargs)
# Access the original undecorated function directly:
print(say_hello.__wrapped__) # <function say_hello at 0x...>
functools.WRAPPER_ASSIGNMENTS in Python 3.12+, which attribute will you see that was not included before 3.12?Common Mistakes
Mistake 1: Forgetting the Parentheses
The single error that causes more confusion than any other with parameterized decorators is forgetting the parentheses:
import functools
def repeat(n=2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
func(*args, **kwargs)
return wrapper
return decorator
# CORRECT: parentheses call the factory first
@repeat(n=3)
def greet():
print("Hello")
# WRONG: no parentheses -- passes greet as n
@repeat # repeat receives greet as the n argument
def greet(): # TypeError when greet() is called
print("Hello")
Without parentheses, Python calls repeat(greet), passing the function object as the n parameter. The factory returns decorator, and the name greet is rebound to decorator. When you later call greet(), Python actually calls decorator() with no arguments, which crashes because decorator expects a func argument.
Always include parentheses when using a decorator factory, even when you want all default values: @repeat(), not @repeat. The parentheses are what trigger the factory call. Without them, the factory receives the function instead of its intended configuration.
Mistake 2: Applying @wraps to the Wrong Layer
import functools
def log_calls(level="INFO"):
def decorator(func):
# WRONG: @wraps applied to decorator instead of wrapper
# This copies func's metadata onto decorator, not wrapper
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level="DEBUG")
def calculate(a, b):
"""Add two numbers."""
return a + b
# Metadata is lost because wrapper never got @wraps:
print(calculate.__name__) # wrapper
print(calculate.__doc__) # None
The fix is to place @functools.wraps(func) directly above def wrapper:
import functools
def log_calls(level="INFO"):
def decorator(func):
@functools.wraps(func) # CORRECT: on the wrapper
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level="DEBUG")
def calculate(a, b):
"""Add two numbers."""
return a + b
print(calculate.__name__) # calculate
print(calculate.__doc__) # Add two numbers.
add.__name__ prints "wrapper". Why?import functools
def log_calls(level="INFO"):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level="DEBUG")
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # wrapperMistake 3: Forgetting to Return the Wrapper's Result
A subtler error is forgetting to return the result of func(*args, **kwargs) inside the wrapper. The decorator runs, the original function runs, but the caller receives None instead of the expected return value:
import functools
def log_calls(level="INFO"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
func(*args, **kwargs) # BUG: missing return
return wrapper
return decorator
@log_calls(level="DEBUG")
def add(a, b):
"""Add two numbers."""
return a + b
result = add(3, 4)
print(result) # None -- the return value was swallowed
The fix is a single word: return func(*args, **kwargs). This mistake is especially insidious because the decorated function still runs correctly—the side effect (the print) happens, and no error is raised. The None only shows up when the caller tries to use the return value.
Stacking Parameterized Decorators
When you stack multiple parameterized decorators, the order matters. The decorator closest to the def is applied first, and the outermost decorator wraps the result. Each decorator in the stack should use @functools.wraps(func) to preserve the original function's metadata through the chain. If even one decorator in the stack omits @wraps, the metadata is lost for all decorators above it:
import functools
def log_calls(level="INFO"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
def retry(max_attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
if attempt == max_attempts:
raise
return wrapper
return decorator
# retry wraps the original, then log_calls wraps the retried version
@log_calls(level="DEBUG")
@retry(max_attempts=3)
def fetch(url):
"""Fetch a URL."""
return f"Response from {url}"
# Metadata still intact through the full chain:
print(fetch.__name__) # fetch
print(fetch.__doc__) # Fetch a URL.
Real-World Examples
The three-layer pattern becomes second nature once you build a few real decorators with it. Each of the following examples uses the same structure: the factory captures configuration, the decorator captures the function, the wrapper executes the logic, and @functools.wraps(func) sits on the wrapper to preserve metadata. Pay attention to where func is available in each layer—it only exists inside the decorator and wrapper, never in the factory.
Retry with Configurable Attempts and Delay
import functools
import time
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""Retry a function on failure with configurable behavior."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_exc = exc
if attempt < max_attempts:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
"""Fetch data from a remote API endpoint."""
import random
if random.random() < 0.6:
raise ConnectionError("Server unreachable")
return {"status": "ok", "url": url}
print(fetch_data.__name__) # fetch_data
print(fetch_data.__doc__) # Fetch data from a remote API endpoint.
print(fetch_data.__wrapped__) # <function fetch_data at 0x...>
The exceptions parameter is a tuple of exception types that trigger a retry. This uses closure scoping: the max_attempts, delay, and exceptions values are captured by the factory and remain accessible inside the wrapper through the closure chain, even though the factory has long since returned. Each decorated function gets its own independent closure with its own configuration values.
Role-Based Access Control
Authentication and authorization decorators are a common use case in web frameworks. The factory receives the required role, and the wrapper checks the user's role before allowing the function to execute. Notice that the wrapper modifies the function's effective signature by requiring a user argument as the first parameter—this is acceptable because the decorator's purpose is to enforce that the function receives a user context:
import functools
def require_role(role):
"""Restrict function access to users with a specific role."""
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
raise PermissionError(
f"{func.__name__} requires '{role}' role, "
f"but user has '{user.get('role')}'"
)
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_account(user, account_id):
"""Permanently delete a user account."""
return f"Account {account_id} deleted"
# Works for admin users:
admin = {"id": 1, "role": "admin"}
print(delete_account(admin, 42)) # Account 42 deleted
# Raises for non-admin users:
viewer = {"id": 2, "role": "viewer"}
# delete_account(viewer, 42) # PermissionError
wrapper function access the n argument from the factory, even though the factory has already returned?The Optional-Arguments Pattern
There is a common annoyance with decorator factories: you must always include parentheses, even when you want all defaults. Writing @retry fails while @retry() works. The optional-arguments pattern eliminates this requirement by detecting whether the decorator was called with or without parentheses. This pattern is documented in Python Cookbook, 3rd Edition by David Beazley and Brian K. Jones (O'Reilly, 2013, Recipe 9.6) and is the approach recommended by Real Python's decorator primer:
import functools
def log_calls(func=None, *, level="INFO"):
"""Decorator that works with or without arguments.
Usage:
@log_calls -- uses default level="INFO"
@log_calls() -- same, explicit empty call
@log_calls(level="DEBUG") -- custom level
"""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {f.__name__}")
result = f(*args, **kwargs)
print(f"[{level}] {f.__name__} returned {result!r}")
return result
return wrapper
if func is not None:
# Called without arguments: @log_calls
return decorator(func)
# Called with arguments: @log_calls() or @log_calls(level="DEBUG")
return decorator
The trick is the func=None first parameter followed by keyword-only arguments (enforced by the * separator). When used as @log_calls without parentheses, Python passes the decorated function as func, so the factory detects that func is not None and applies the decorator immediately. When used as @log_calls(level="DEBUG"), func remains None, and the factory returns the decorator for later application.
# All three forms work correctly:
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
@log_calls()
def subtract(a, b):
"""Subtract b from a."""
return a - b
@log_calls(level="DEBUG")
def multiply(a, b):
"""Multiply two numbers."""
return a * b
# All three preserve metadata:
print(add.__name__) # add
print(subtract.__name__) # subtract
print(multiply.__name__) # multiply
The * separator in def log_calls(func=None, *, level="INFO") forces level to be a keyword-only argument. This is what prevents ambiguity: @log_calls("DEBUG") would pass "DEBUG" as func, but @log_calls(level="DEBUG") correctly passes it as configuration. Without the *, the pattern would break for positional arguments.
* separator do in def log_calls(func=None, *, level="INFO")?Alternative: The functools.partial Approach
An alternative to the if func is not None pattern uses functools.partial to return a partially applied version of the factory when arguments are provided. This avoids the inner decorator function entirely when the decorator is called without arguments:
import functools
def log_calls(func=None, *, level="INFO"):
"""Decorator using functools.partial for the optional-arguments pattern."""
if func is None:
return functools.partial(log_calls, level=level)
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[{level}] {func.__name__} returned {result!r}")
return result
return wrapper
# All three forms work:
@log_calls
def add(a, b):
return a + b
@log_calls()
def subtract(a, b):
return a - b
@log_calls(level="DEBUG")
def multiply(a, b):
return a * b
When func is None (called with arguments), functools.partial(log_calls, level=level) returns a new callable that has level already bound. When Python then passes the decorated function to this partial, it triggers the func is not None branch and applies the decorator. This approach is functionally identical to the nested-function version above but eliminates one level of nesting.
Class-Based Decorator Factory
A class can serve as a decorator factory by receiving configuration in __init__ and the function in __call__. The __call__ method returns a wrapper function, and @functools.wraps(func) goes on that wrapper—exactly as in the function-based pattern:
import functools
import time
class RateLimit:
"""Class-based decorator factory that limits call frequency."""
def __init__(self, calls_per_second=1.0):
self.min_interval = 1.0 / calls_per_second
self.last_called = 0.0
def __call__(self, func):
@functools.wraps(func) # Same rule: @wraps goes on the wrapper
def wrapper(*args, **kwargs):
elapsed = time.monotonic() - self.last_called
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_called = time.monotonic()
return func(*args, **kwargs)
return wrapper
@RateLimit(calls_per_second=2)
def send_request(url):
"""Send an HTTP request to the given URL."""
print(f"Requesting {url}")
print(send_request.__name__) # send_request
print(send_request.__doc__) # Send an HTTP request to the given URL.
The class instance (RateLimit(calls_per_second=2)) acts as the factory layer. When Python calls the instance with the decorated function, __call__ acts as the decorator layer. The inner wrapper function is the wrapper layer. The three-layer structure is preserved, just distributed across a class instead of nested functions.
Key Takeaways
- Parameterized decorators have three layers: The factory receives configuration arguments. The decorator receives the function. The wrapper receives the call arguments and replaces the original function.
@functools.wraps(func)goes on the wrapper—always the innermost function. This structure is the same whether you use nested functions or a class-based factory. - The rule is: apply
@wrapsto whatever replaces the original: In a two-layer decorator, that is the wrapper. In a three-layer parameterized decorator, it is still the wrapper. In a class-based factory where__call__returns a function, it goes on that returned function. @functools.wrapscopies six attributes plus__wrapped__: It assigns__module__,__name__,__qualname__,__annotations__,__type_params__(since Python 3.12), and__doc__from the original to the wrapper. It also updates the wrapper's__dict__and sets__wrapped__to point to the original function (source).inspect.signature()follows__wrapped__: Since Python 3.5,inspect.signature()traverses the__wrapped__chain to report the original function's parameter signature. This is whyhelp()shows correct parameter names on properly decorated functions (source).- Always include parentheses with decorator factories:
@factory()calls the factory and then applies the returned decorator.@factorypasses the function as the first factory argument, which causes aTypeError. Use@factory()even for all-default arguments. - The optional-arguments pattern eliminates the parentheses requirement: Use
def factory(func=None, *, config=default)with the*separator to allow@factory,@factory(), and@factory(config=value)to all work correctly. This pattern was popularized by Python Cookbook, 3rd Edition (O'Reilly, 2013, Recipe 9.6) and can also be implemented usingfunctools.partial. - Applying
@wrapsto the wrong layer is a silent bug: The code runs without errors, but the decorated function shows "wrapper" as its name and loses its docstring. If your decorated function's metadata is wrong, check which layer has@functools.wraps—it needs to be on the innermost function, not the middle one.
The nesting in parameterized decorators looks intimidating at first, but the mental model is simple: the outer function captures configuration, the middle function captures the original function, and the inner function does the work. @functools.wraps goes on the inner function because that is the one that steals the original's name. Get that placement right, and everything else follows. For the complete API reference including WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES, and the underlying update_wrapper() function, see the official Python functools documentation. For background on why the @ syntax was chosen for decorators, see PEP 318.
Frequently Asked Questions
Where do I put @functools.wraps in a decorator that takes arguments?
Place @functools.wraps(func) on the innermost function—the wrapper that uses *args and **kwargs and calls the original function. In a parameterized decorator with three layers (factory, decorator, wrapper), it goes on the wrapper, which is the function returned to replace the original. Never place it on the factory or the middle decorator function.
Why does my parameterized decorator fail when I forget the parentheses?
When you write @my_decorator instead of @my_decorator() on a parameterized decorator, Python passes the decorated function as the first configuration argument instead of calling the factory first. This causes a TypeError because the factory tries to use a function object where it expected a configuration value. Always include parentheses when using a decorator factory, even if you want all default arguments.
How do I make a decorator that works both with and without arguments?
Use the optional-arguments pattern: define the factory function with func=None as the first parameter, followed by keyword-only configuration parameters enforced by the * separator. If func is provided (decorator used without parentheses), apply the decorator immediately. If func is None (decorator used with parentheses), return the decorator for later application. This pattern is documented in Python Cookbook, 3rd Edition by David Beazley and Brian K. Jones (O'Reilly, 2013, Recipe 9.6).
What happens if I put @functools.wraps on the wrong layer?
If you apply @functools.wraps(func) to the middle decorator function instead of the innermost wrapper, the decorator function itself gets the original function's metadata, but the wrapper that replaces the original does not. The decorated function will still report the wrapper's name because Python rebinds the original name to the wrapper.
What attributes does functools.wraps copy from the original function?
As of Python 3.12 and later, functools.wraps copies six attributes via WRAPPER_ASSIGNMENTS: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. It also updates the wrapper's __dict__ via WRAPPER_UPDATES and automatically adds a __wrapped__ attribute pointing to the original function (source).
Can I use functools.wraps in a class-based decorator factory?
Yes. When a class serves as a decorator factory (receiving configuration in __init__ and the function in __call__), the __call__ method returns a wrapper function. Apply @functools.wraps(func) to that inner wrapper, exactly as you would in a function-based factory.
How does inspect.signature() interact with functools.wraps?
Since Python 3.5, inspect.signature() follows the __wrapped__ attribute set by functools.wraps to report the original function's parameter signature rather than the wrapper's generic (*args, **kwargs). Pass follow_wrapped=False to get the wrapper's own signature instead (source).