Decorator Design
Writing a decorator that behaves well for every caller takes a few disciplined habits: preserve metadata, accept any signature, stack cleanly, and avoid hidden surprises.
Once you understand the mechanics of decorators and class decorators, the next skill is writing them well enough to hand to other people (or your future self) without landing traps. This topic collects the practical design rules that separate a decorator that 'happens to work in one test' from one that's safe to use broadly: preserving the wrapped function's identity, accepting arbitrary arguments, composing predictably with other decorators, and not doing anything surprising.
Rule 1: Always Preserve Metadata with functools.wraps
Every decorator meant for reuse should apply functools.wraps(func) to its inner wrapper. This isn't cosmetic — code that inspects __name__, __doc__, or __module__ (documentation generators, debuggers, frameworks that dispatch by function name, pickling in some cases) will silently misbehave on an unwrapped decorator.
Without functools.wraps, both __name__ and __doc__ would report wrapper's own (empty) metadata instead of distance's, and the error message inside wrapper would even print 'wrapper' instead of 'distance'.
import functools
def ensure_positive(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if result < 0:
raise ValueError(f"{func.__name__} returned a negative value")
return result
return wrapper
@ensure_positive
def distance(a: float, b: float) -> float:
"""Return the distance between a and b."""
return abs(a - b)
print(distance.__name__, "-", distance.__doc__)
# distance - Return the distance between a and b.Rule 2: Accept Arbitrary Arguments Unless You Have a Specific Reason Not To
A general-purpose decorator's wrapper should be written as def wrapper(*args, **kwargs) and forward exactly what it received, unless the decorator has a documented, deliberate reason to constrain the signature (e.g. a decorator specifically designed only for zero-argument functions).
Because wrapper forwards *args and **kwargs verbatim, it works unmodified whether connect is called positionally, with keywords, or with defaults left out.
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"{func.__name__}(args={args}, kwargs={kwargs})")
return func(*args, **kwargs)
return wrapper
@trace
def connect(host: str, port: int = 443, *, timeout: float = 5.0) -> str:
return f"connected to {host}:{port}"
connect("example.com", timeout=2.0)
# connect(args=('example.com',), kwargs={'timeout': 2.0})Rule 3: Stacking Decorators — Order Matters
Multiple @decorator lines apply bottom-up: the one closest to the def wraps the original function first, and each decorator above it wraps the result of the one below. Reading @a then @b then def f is equivalent to f = a(b(f)) — b's wrapper is the innermost layer, a's wrapper is the outermost.
italic wraps greet first (innermost), then bold wraps the result of that (outermost) — so the output is bold-around-italic, matching the bottom-up reading order and equal to bold(italic(greet)).
When stacking decorators that affect control flow (retry, timing, caching, exception handling), think carefully about order: a caching decorator placed OUTSIDE a logging decorator means cache hits never reach the logging code, while placing it INSIDE means every call is logged even on a cache hit.
def bold(func):
def wrapper(*a, **k):
return f"<b>{func(*a, **k)}</b>"
return wrapper
def italic(func):
def wrapper(*a, **k):
return f"<i>{func(*a, **k)}</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Ana")) # <b><i>Hello, Ana</i></b>Rule 4: Keep Wrappers Behaviorally Transparent
A well-designed decorator should not silently change what exceptions propagate, what the return value means, or whether the function is still callable the same way — unless that IS the decorator's explicit, documented purpose (like a retry or validation decorator). Swallowing exceptions or altering return values as an unannounced side effect is a common source of very confusing bugs.
unsafe_guard turns every failure into a silent None, which callers will misinterpret as a legitimate result; safe_guard observes the failure (for logging) but still lets it propagate, matching the caller's expectations.
# Surprising: silently swallows all exceptions and returns None
def unsafe_guard(func):
def wrapper(*a, **k):
try:
return func(*a, **k)
except Exception:
return None # caller has no idea something failed
return wrapper
# Transparent: logs, then re-raises — caller still sees the real failure
def safe_guard(func):
import functools
@functools.wraps(func)
def wrapper(*a, **k):
try:
return func(*a, **k)
except Exception:
print(f"{func.__name__} raised an exception")
raise
return wrapperRule 5: Avoid Unnecessary Shared Mutable State Between Calls
A decorator's closure persists across all calls to the decorated function, which is exactly what makes caching decorators work — but it also means any mutable state you introduce (a list, a dict, a counter) is shared across every call, and across every caller, of that one decorated function. Be deliberate about whether that's the intent (e.g. a cache) or an accidental bug (e.g. a list meant to be per-call that instead accumulates forever).
Storing the counter as an attribute on wrapper itself (rather than a plain closure variable) makes the shared, persistent nature of this state visible and inspectable from outside, which is good design for intentionally shared state.
import functools
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1 # deliberate shared state, clearly named
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
@count_calls
def ping():
return "pong"
ping(); ping(); ping()
print(ping.calls) # 3Handling Decorators That Work With or Without Arguments
A more advanced, optional pattern: some decorators are designed to work both bare (@decorator) and called (@decorator(option=True)). This requires checking, inside the outer function, whether it was called with a single callable argument (bare usage) or with configuration (factory usage) and branching accordingly. It adds real complexity, so use it only when the ergonomic benefit clearly outweighs that cost.
func is None only when trace was called with keyword arguments (factory style, no positional function yet); when trace receives the function directly (bare style), it decorates immediately instead of returning another layer.
import functools
def trace(func=None, *, label=None):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
print(f"[{label or f.__name__}] called")
return f(*args, **kwargs)
return wrapper
if func is not None:
return decorator(func) # used as @trace (bare)
return decorator # used as @trace(label=...) (factory)
@trace
def a(): pass
@trace(label="custom")
def b(): passDocumenting Decorator Behavior
Because decorators change behavior invisibly at the call site, a decorator meant for others to use should have a clear docstring explaining what it does, what it does with exceptions, and any shared/mutable state it introduces — the same discipline you'd want from any function whose effects aren't obvious just from its name and call.
Key Takeaways
Always apply functools.wraps(func) inside a reusable decorator to preserve __name__, __doc__, and related metadata.
Accept *args, **kwargs in the wrapper and forward them, unless deliberately restricting the decorated function's signature.
Stacked decorators apply bottom-up: @a @b def f means f = a(b(f)), with b's wrapper innermost.
Keep wrappers behaviorally transparent — don't silently swallow exceptions or change return semantics unless that is the decorator's documented job.
Shared state in a decorator's closure persists across every call to every use of that decorated function — make deliberate shared state visible (e.g. as an attribute), and avoid accidental shared mutable state.
Supporting both bare (@dec) and parameterized (@dec(...)) usage adds real complexity — only do it when clearly worth the ergonomic win.
def cache(func): results = {} def wrapper(*args): if args not in results: results[args] = func(*args) return results[args] return wrapper # no functools.wraps import functools def cache(func): results = {} @functools.wraps(func) def wrapper(*args): if args not in results: results[args] = func(*args) return results[args] return wrapper
Omitting functools.wraps makes every function decorated with cache report __name__ == 'wrapper', breaking introspection, debugging output, and documentation tools.
@retry_three_times @cache def fetch_data(url): ... # cache is OUTSIDE retry, so a failed call is cached as... nothing useful, and retries never re-run fetch_data
@cache @retry_three_times def fetch_data(url): ... # retry runs first (innermost), THEN the successful result is cached
Decorator order changes behavior: with cache innermost (closest to the function), retry_three_times gets a chance to retry the underlying call before a value is ever cached; the reverse order caches too early or in a way that doesn't compose with retry logic as intended.
def suppress_errors(func): def wrapper(*a, **k): try: return func(*a, **k) except Exception: return None # caller can't tell success from failure return wrapper import functools def suppress_errors(func): @functools.wraps(func) def wrapper(*a, **k): try: return func(*a, **k) except Exception as exc: print(f"{func.__name__} failed: {exc}") raise return wrapper
Silently converting every exception into None hides real failures from the caller; if suppressing errors is truly the intent, document it explicitly and make the suppressed exception observable (e.g. logged) rather than invisible.
Why is functools.wraps considered mandatory rather than optional for a reusable decorator?
Without it, the decorated function's __name__, __doc__, and __module__ all report the wrapper's own metadata instead of the original function's, breaking debugging, documentation tools, and any code that inspects those attributes.
Given @a then @b then def f(): ..., which decorator wraps f first, and what is the equivalent single-line assignment?
b wraps f first (it's closest to the def); the equivalent is f = a(b(f)), meaning a's wrapper is the outermost layer and b's wrapper is the innermost.
Why can decorator order change a program's observable behavior, using a caching decorator and a retry decorator as an example?
If caching is applied before (outside) retry, a call is cached only after all retries finish, so retry logic runs fully on cache misses; if caching is applied after (inside) retry, the semantics of what gets cached and when retries happen change — the two orders are not equivalent.
What is the risk of a decorator's wrapper catching an exception and returning a fixed sentinel value like None instead of re-raising?
The caller loses the ability to distinguish 'the function legitimately returned None' from 'the function failed', which can mask real bugs and make debugging much harder, unless this behavior is deliberate and clearly documented.
Why does storing a counter as wrapper.calls (an attribute on the wrapper function) count as better design than a bare closure variable, for state meant to be inspected from outside?
Because a closure variable is only accessible from inside the closure's own functions, while an attribute on the wrapper object is visible and readable from any code that has a reference to the decorated function, making the shared state discoverable rather than hidden.
What additional complexity does supporting both @decorator and @decorator(option=...) usage introduce?
The outer function must branch on whether it received a single callable positional argument (bare usage) or only keyword/no arguments (factory usage), and return either the wrapped function directly or another decorator function accordingly — effectively two code paths to test and maintain.
Why should a wrapper forward *args, **kwargs rather than naming specific parameters, in a general-purpose decorator?
Naming specific parameters ties the decorator to one function signature; forwarding *args, **kwargs makes the same decorator reusable across functions and methods with entirely different signatures.
Write a decorator validate_types (using functools.wraps) that raises TypeError if any positional argument isn't an int, otherwise calls the function normally.
import functools def validate_types(func): @functools.wraps(func) def wrapper(*args, **kwargs): for arg in args: if not isinstance(arg, int): raise TypeError(f"{func.__name__} expected int, got {type(arg).__name__}") return func(*args, **kwargs) return wrapper @validate_types def add(a, b): return a + b print(add(2, 3)) # 5 add(2, "3") # raises TypeError wrapper inspects every positional argument before delegating to func; functools.wraps keeps add.__name__ as 'add' so the TypeError message correctly names the real function.
What does this print?
[LOG] hello [END]. add_suffix wraps message first (innermost, closest to def), producing 'hello [END]'; add_prefix then wraps that result, prepending '[LOG] ' — matching f = add_prefix(add_suffix(message)).
Write a decorator count_calls that tracks how many times the decorated function has been called, exposing the count as an attribute .calls on the decorated function (not hidden in a closure).
import functools def count_calls(func): @functools.wraps(func) def wrapper(*args, **kwargs): wrapper.calls += 1 return func(*args, **kwargs) wrapper.calls = 0 return wrapper @count_calls def ping(): return "pong" ping() ping() print(ping.calls) # 2 wrapper.calls is initialized once on the function object itself right after wrapper is defined, then incremented on every call — making the shared call-count state visible and readable as ping.calls from outside.
caught inf 5.0 The first call raises ZeroDivisionError inside func, which wrapper catches, prints 'caught', and returns float('inf') instead of propagating; the second call succeeds normally and returns 5.0, with no exception to catch.
def add_prefix(func):
def wrapper(*a, **k):
return "[LOG] " + func(*a, **k)
return wrapper
def add_suffix(func):
def wrapper(*a, **k):
return func(*a, **k) + " [END]"
return wrapper
@add_prefix
@add_suffix
def message():
return "hello"
print(message())import functools
def guard(func):
@functools.wraps(func)
def wrapper(*a, **k):
try:
return func(*a, **k)
except ZeroDivisionError:
print('caught')
return float('inf')
return wrapper
@guard
def divide(a, b):
return a / b
print(divide(10, 0))
print(divide(10, 2))