PYTHON / ADVANCED PYTHON
Decorators with arguments and functools.wraps
Build decorators that take their own arguments using a three-layer factory, and preserve the wrapped function's identity with functools.wraps.
What you will learn
- Read @deco(arg) as func = deco(arg)(func) and write the three matching layers
- Capture factory arguments in the closure, evaluated once at decoration time
- Apply @functools.wraps(func) to the wrapper to copy __name__, __doc__ and __wrapped__
- Use inspect.signature and __wrapped__ to confirm introspection still reaches the original
Understanding Decorators with arguments and functools.wraps
A decorator is nothing more than a callable that takes one function and returns a replacement. When you write @repeat(3), Python first evaluates the expression repeat(3), then calls whatever it returns with the decorated function: greet = repeat(3)(greet). That extra call is why a parameterised decorator needs three nested layers instead of two — the factory receives the configuration, the decorator it returns receives the function, and the wrapper it returns receives the actual call arguments.
The layers are also where the values live. times is bound once, when the class or module body executes, and the wrapper reads it out of the enclosing scope's cell on every call. So configuration is per-decoration, not per-call: two functions decorated with @repeat(2) and @repeat(5) each get their own closure with their own cell, and nothing is re-parsed at call time. If you pass a mutable object to the factory, every call of that one decorated function shares it.
The wrapper you return is a brand-new function object, so it carries its own metadata: its __name__ is "wrapper", its __doc__ is None, its __module__ points at wherever the decorator was defined. functools.wraps(func) is a decorator that copies __module__, __name__, __qualname__, __doc__ and __dict__ from func onto the wrapper and sets wrapper.__wrapped__ = func. That last attribute is what lets inspect.signature and help() look through the wrapper and report the original parameters, and it is why frameworks that key on function names — Flask routes, pytest collection, pickle lookups by qualname — keep working after decoration.
import functools
def repeat(times):
def decorator(func):
functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
repeat(times=3)
def greet(name):
"""Say hello once."""
print(f"hello {name}")
return name.upper()
print(greet("ada"))
print(greet.__name__, "|", greet.__doc__)
print(greet.__wrapped__.__name__)@deco(arg) means deco(arg)(func), so a parameterised decorator is a factory returning a decorator returning a wrapper, and functools.wraps repairs the metadata that wrapper would otherwise lose.
Worked examples
What the wrapper loses without wraps
Compares the metadata and reported signature of the same wrapper written with and without functools.wraps.
import functools
import inspect
def log_calls(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def log_calls_fixed(func):
functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def area(width, height=2):
"""Rectangle area."""
return width * height
bare = log_calls(area)
fixed = log_calls_fixed(area)
print(bare.__name__, inspect.signature(bare), bare.__doc__)
print(fixed.__name__, inspect.signature(fixed), fixed.__doc__)Example explained
Line 1bare.__name__ is "wrapper" because that is the name of the function object actually returned.
Line 2inspect.signature(bare) reports (*args, **kwargs), the wrapper's real parameters, so help() and IDEs show nothing useful.
Line 3functools.wraps copies __name__ and __doc__ across, and sets __wrapped__ = area.
Line 4inspect.signature follows __wrapped__ by default, which is why fixed reports (width, height=2) even though the wrapper still literally takes *args.
A decorator usable with and without parentheses
Uses a keyword-only argument plus functools.partial so both @tag and @tag(name="p") work.
import functools
def tag(func=None, *, name="div"):
if func is None:
return functools.partial(tag, name=name)
functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<{name}>{func(*args, **kwargs)}</{name}>"
return wrapper
tag
def plain():
return "a"
tag(name="p")
def para():
return "b"
print(plain())
print(para())
print(plain.__name__, para.__name__)Example explained
Line 1@tag with no parentheses calls tag(plain), so func is the function and the wrapper is built immediately.
Line 2@tag(name="p") calls tag(name="p") with func left as None, so tag returns a partial that is then called with para.
Line 3Making name keyword-only prevents the ambiguous call tag(something) where something might be a function or a tag name.
Line 4Both paths run the same @functools.wraps(func) line, so both decorated functions keep their original names.
Stacking two configured decorators
Shows the bottom-up application order of stacked decorator factories and how each keeps its own captured argument.
import functools
def wrap_in(label):
def decorator(func):
functools.wraps(func)
def wrapper(*args, **kwargs):
return f"{label}({func(*args, **kwargs)})"
return wrapper
return decorator
wrap_in("outer")
wrap_in("inner")
def value():
return "x"
print(value())
print(value.__name__)Example explained
Line 1The lower decorator runs first: value = wrap_in("outer")(wrap_in("inner")(value)).
Line 2Each call to wrap_in creates a separate closure cell, so "inner" and "outer" do not overwrite each other.
Line 3The outer wrapper calls the inner wrapper, which calls the original, so the labels nest from outside in.
Line 4Because both layers use wraps, the name copied through the whole chain is still "value" rather than "wrapper".
Important notes
wraps copies metadata only; it does not change what the wrapper really accepts, so a wrapper that adds or drops a parameter will report a signature that lies to callers.
In the repeat example, times=0 makes the loop body never run and result never get assigned, so the wrapper raises UnboundLocalError instead of returning None.
Common mistakes
Applying a factory without parentheses: @repeat instead of @repeat(3) binds greet to the argument name times, so repeat returns decorator and greet becomes that decorator; calling greet("ada") silently returns a wrapper function object instead of printing anything.
Writing @functools.wraps without calling it, so wraps receives wrapper as its wrapped argument and returns a partial; the failure only appears at call time as an AttributeError when it tries to set __name__ on your first argument.
Putting @functools.wraps(func) above decorator instead of above wrapper, which copies metadata onto the wrong layer and leaves the actually returned function still named "wrapper".
Try it yourself
Change, predict, then run
Write a decorator factory prefix(text) that returns the decorated function's result with text prepended, apply it as @prefix(">> ") to a function that returns a string, and print both the result and the decorated function's __name__ and __doc__ to confirm functools.wraps is doing its job.
Open the Python workspaceCheck your understanding
A parameterised decorator such as @retry(3) needs three nested function layers, while a plain @retry needs only two. Why?
- Because Python passes the decorator arguments to the innermost wrapper, which needs its own scope for them.
- Because functools.wraps needs a separate layer in which to copy the metadata.
- Because @retry(3) evaluates retry(3) first, and that result must itself be a callable that accepts the decorated function.
- Because each decorator argument needs its own closure cell to avoid late binding.
Show answer
The @ line is an expression followed by a call: retry(3) is evaluated, then its result is called with the function, so you need one layer for the arguments, one for the function and one for the call. The closure-cell option is tempting because the arguments really are stored in cells, but a single function can hold any number of cells; nesting is driven by the number of calls, not the number of arguments.