Writing decorators from scratch every time invites inconsistency. You forget functools.wraps on one, omit the return value on another, and break type checking on a third. This article provides five decorator templates that you can copy directly into any project. Each template satisfies the same three requirements: it works with any function signature, it preserves function metadata for help() and debuggers, and it passes the original return value through to the caller. The templates progress from the simplest possible decorator to a fully type-annotated version using ParamSpec from PEP 612.
A "well-behaved" decorator is one that is transparent to both the caller and the developer reading the code. After decoration, the function's __name__, __doc__, __qualname__, and __module__ still match the original function. help() shows the correct signature and docstring. inspect.signature() returns the original parameter list. The return value passes through unchanged. And if the developer uses a type checker, the decorated function's type signature matches the original. When a decorator achieves all of this, it is invisible in every way except the behavior it adds.
The Three Rules Every Decorator Must Follow
Before looking at the templates, these three rules define the baseline for any decorator that does not silently break the code it touches.
Rule 1: Accept any function signature. The wrapper function must use *args and **kwargs so it works with any function regardless of how many positional or keyword arguments it accepts. Decorators that hardcode specific parameter names only work with one specific function shape and break when applied to anything else.
Rule 2: Preserve metadata with @functools.wraps(func). Without this, the decorated function loses its __name__, __doc__, __qualname__, __module__, and __annotations__. This breaks help(), debugger output, documentation generators, serialization, and framework routing that depends on function names. functools.wraps also adds a __wrapped__ attribute that inspect.signature() follows to report the correct parameter list.
Rule 3: Return the original function's result. The wrapper must capture the return value of func(*args, **kwargs) and explicitly return it. Omitting the return statement is one of the most common decorator bugs. It causes every decorated function to silently return None, regardless of what the original function returns.
Five Templates, From Simple to Advanced
Template 1: Basic Decorator
This is the foundation. Every other template builds on this structure.
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# logic before the function runs
result = func(*args, **kwargs)
# logic after the function runs
return result
return wrapper
Usage:
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def compute_primes(limit):
"""Return all primes below the given limit."""
sieve = [True] * limit
sieve[0] = sieve[1] = False
for i in range(2, int(limit**0.5) + 1):
if sieve[i]:
for j in range(i*i, limit, i):
sieve[j] = False
return [i for i, is_prime in enumerate(sieve) if is_prime]
primes = compute_primes(1_000_000)
# compute_primes took 0.0732s
print(compute_primes.__name__) # compute_primes
print(compute_primes.__doc__) # Return all primes below the given limit.
This template covers the vast majority of decorator use cases. Use it for logging, timing, access control, input validation, or any behavior that runs before and/or after the function.
Template 2: Parameterized Decorator (Decorator Factory)
When a decorator needs configuration, it requires a third layer of nesting. The outer function accepts the configuration parameters and returns the actual decorator.
import functools
def my_decorator(param1, param2="default"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# use param1 and param2 here
result = func(*args, **kwargs)
return result
return wrapper
return decorator
Usage:
import functools
import time
def retry(max_attempts, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
if attempt < max_attempts:
time.sleep(delay)
raise last_error
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_price(ticker):
"""Fetch the current price for a stock ticker."""
import random
if random.random() < 0.6:
raise ConnectionError("Service unavailable")
return 142.50
print(fetch_price.__name__) # fetch_price
The parentheses are required when using this template: @retry(max_attempts=3) calls the outer function, which returns the decorator, which then wraps the function. Writing @retry without parentheses would pass the function as max_attempts, causing a TypeError.
Template 3: Optional-Argument Decorator
This template allows a decorator to be used both with and without parentheses: @my_decorator, @my_decorator(), and @my_decorator(option=value) all work. The trick is using a sentinel check on the first argument.
import functools
def my_decorator(func=None, *, option_a="default", option_b=False):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# use option_a and option_b here
result = func(*args, **kwargs)
return result
return wrapper
if func is not None:
# Called as @my_decorator without parentheses
return decorator(func)
# Called as @my_decorator() or @my_decorator(option_a="value")
return decorator
Usage:
import functools
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def log_calls(func=None, *, level=logging.INFO):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.log(level, "Calling %s", func.__name__)
result = func(*args, **kwargs)
logger.log(level, "%s returned %r", func.__name__, result)
return result
return wrapper
if func is not None:
return decorator(func)
return decorator
# All three forms work:
@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=logging.DEBUG)
def multiply(a, b):
"""Multiply two numbers."""
return a * b
print(add(3, 4)) # 7
print(subtract(10, 3)) # 7
print(multiply(5, 6)) # 30
The bare * after func in the signature forces all configuration arguments to be keyword-only. This prevents ambiguity: when the decorator is called as @log_calls, Python passes the decorated function as func. When called as @log_calls(level=logging.DEBUG), func is None and the function arrives later through the returned decorator.
This pattern relies on the fact that a function object will never be None. The check if func is not None distinguishes between bare usage (@log_calls) and parameterized usage (@log_calls() or @log_calls(level=...)).
Template 4: Class-Based Decorator (Stateful)
When a decorator needs to maintain state across calls, a class is cleaner than using nonlocal variables in nested closures. Use functools.update_wrapper instead of functools.wraps.
import functools
class MyDecorator:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
# initialize state here
def __call__(self, *args, **kwargs):
# logic before
result = self.func(*args, **kwargs)
# logic after
return result
Usage:
import functools
import time
class RateLimit:
"""Enforce a maximum call frequency on a function."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.last_called = 0.0
self.min_interval = 1.0 # seconds
def __call__(self, *args, **kwargs):
now = time.time()
elapsed = now - self.last_called
if elapsed < self.min_interval:
wait = self.min_interval - elapsed
raise RuntimeError(
f"Rate limited. Try again in {wait:.2f}s"
)
self.last_called = now
return self.func(*args, **kwargs)
@RateLimit
def send_alert(message):
"""Send an alert notification."""
return f"Alert sent: {message}"
print(send_alert.__name__) # send_alert
print(send_alert.__doc__) # Send an alert notification.
print(send_alert("Disk full"))
try:
send_alert("CPU high") # called too soon
except RuntimeError as e:
print(e)
# Rate limited. Try again in 0.99s
The state (last_called) lives as an instance attribute on the decorator object. Each decorated function gets its own independent state because each @RateLimit application creates a new class instance.
Template 5: Type-Safe Decorator With ParamSpec (Python 3.10+)
PEP 612 introduced ParamSpec, a type variable that captures a function's entire parameter list. Using it in your decorator's type annotations allows type checkers like mypy and Pyright to verify that the decorated function's signature is preserved through the decorator, enabling correct autocompletion and catching type errors at call sites.
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def my_decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# logic before
result = func(*args, **kwargs)
# logic after
return result
return wrapper
Usage:
import functools
import time
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timer(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def calculate_discount(price: float, percentage: float) -> float:
"""Apply a percentage discount to a price."""
return price * (1 - percentage / 100)
# Type checker sees: calculate_discount(price: float, percentage: float) -> float
result: float = calculate_discount(99.99, 15)
# This would be caught by mypy:
# calculate_discount("not a number", 15) # error: Argument 1 has incompatible type
The Callable[P, R] -> Callable[P, R] annotation tells the type checker that the output callable has the exact same parameter list (P) and return type (R) as the input. The P.args and P.kwargs annotations on the wrapper ensure that the type checker can trace argument types through the wrapper to the original function call.
For Python 3.8 and 3.9, ParamSpec is available through typing_extensions: from typing_extensions import ParamSpec. This backport provides the same type-checking behavior in older Python versions.
| Template | Use When | Nesting Levels |
|---|---|---|
| Basic | No configuration needed | 2 |
| Parameterized | Decorator requires arguments | 3 |
| Optional-Argument | Arguments should be optional | 2-3 (dynamic) |
| Class-Based | Decorator maintains state | 1 (class) |
| Type-Safe (ParamSpec) | Type checking is required | 2 |
Making Any Template Async-Compatible
Any of the five templates above can be extended to work with both synchronous and asynchronous functions. The key is checking whether the decorated function is a coroutine function and defining the appropriate wrapper type.
import asyncio
import functools
import time
def timer(func):
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_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 async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return sync_wrapper
# Works with sync functions
@timer
def compute(n):
"""Compute the sum of range(n)."""
return sum(range(n))
# Works with async functions
@timer
async def fetch(url):
"""Fetch data from a URL."""
await asyncio.sleep(0.1) # simulating network I/O
return f"Response from {url}"
print(compute(1_000_000))
# compute took 0.0234s
asyncio.run(fetch("https://example.com"))
# fetch took 0.1003s
asyncio.iscoroutinefunction(func) returns True if the function was defined with async def. When it is, the decorator defines an async wrapper that uses await to call the original function. When it is not, the decorator defines a standard synchronous wrapper. Both paths apply @functools.wraps(func) and return the result.
A common mistake is wrapping an async function with a synchronous wrapper that calls func(*args, **kwargs) without await. This does not raise an error immediately. Instead, it returns a coroutine object instead of the expected result, which causes confusing bugs downstream. Always check asyncio.iscoroutinefunction(func) and await accordingly.
Key Takeaways
- Every decorator must follow three rules. Accept any function signature with
*args, **kwargs. Preserve metadata with@functools.wraps(func). Return the original function's result with an explicitreturnstatement. Breaking any of these rules produces a decorator that silently corrupts the functions it touches. - Choose the template that matches your needs. Use Template 1 (basic) for stateless behavior with no configuration. Use Template 2 (parameterized) when the decorator requires arguments that must be provided at decoration time. Use Template 3 (optional-argument) when you want the decorator to work both with and without parentheses. Use Template 4 (class-based) when the decorator needs to maintain state across calls. Use Template 5 (type-safe) when type checking is part of your workflow.
- Place
@functools.wraps(func)on the innermost function. In two-level decorators, it goes on the wrapper. In three-level parameterized decorators, it goes on the innermost wrapper, not the middle decorator function. In class-based decorators, callfunctools.update_wrapper(self, func)in__init__. - Use
ParamSpecfor type safety. PEP 612 introducedParamSpecin Python 3.10 to allow type checkers to verify that decorators preserve parameter types. Annotating your decorator asCallable[P, R] -> Callable[P, R]enables correct IDE autocompletion and catches type mismatches at call sites. For Python 3.8-3.9, usetyping_extensions. - Handle async functions explicitly. Check
asyncio.iscoroutinefunction(func)and define separate async and sync wrapper paths. Wrapping an async function with a sync wrapper that forgetsawaitreturns a coroutine object instead of the expected result, causing bugs that are difficult to trace.
The value of these templates is consistency. When every decorator in a codebase follows the same structure, developers can read any decorator and immediately identify where the pre-call logic is, where the post-call logic is, and where the original function is invoked. The structure becomes invisible, and the behavior becomes the focus. Copy the template that fits your use case, fill in the behavior, and the decorator will be transparent to callers, debuggers, documentation generators, and type checkers.