Standard Template for Well-Behaved Python Decorators

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.

Note

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.

Pro Tip

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
BasicNo configuration needed2
ParameterizedDecorator requires arguments3
Optional-ArgumentArguments should be optional2-3 (dynamic)
Class-BasedDecorator maintains state1 (class)
Type-Safe (ParamSpec)Type checking is required2

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.

Warning

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

  1. 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 explicit return statement. Breaking any of these rules produces a decorator that silently corrupts the functions it touches.
  2. 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.
  3. 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, call functools.update_wrapper(self, func) in __init__.
  4. Use ParamSpec for type safety. PEP 612 introduced ParamSpec in Python 3.10 to allow type checkers to verify that decorators preserve parameter types. Annotating your decorator as Callable[P, R] -> Callable[P, R] enables correct IDE autocompletion and catches type mismatches at call sites. For Python 3.8-3.9, use typing_extensions.
  5. 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 forgets await returns 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.