How to create decorators¶
This guide covers practical patterns for writing decorators in Python. A decorator is a function that takes another function as an argument and returns a modified version of it, allowing you to extend behaviour without changing the original function.
Writing a basic decorator¶
A decorator is a function that wraps another function. The wrapper function runs before and after the original function, adding extra behaviour.
def log_call(func):
"""Decorator that logs when a function is called."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_call
def add(a: int, b: int) -> int:
return a + b
add(3, 5)
The @log_call syntax is equivalent to writing add = log_call(add). When you call add(3, 5), you are actually calling wrapper(3, 5), which in turn calls the original add.
Preserving function metadata with functools.wraps¶
Without functools.wraps, the decorated function loses its original name, docstring, and other metadata. Always use @functools.wraps on your wrapper function.
import functools
def log_call(func):
"""Decorator that logs when a function is called."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_call
def multiply(a: int, b: int) -> int:
"""Return the product of a and b."""
return a * b
print(f"Function name: {multiply.__name__}")
print(f"Docstring: {multiply.__doc__}")
multiply(4, 7)
Creating a decorator with arguments¶
To pass arguments to a decorator, you need an extra layer of nesting. This pattern is called a decorator factory — a function that returns a decorator.
import functools
def repeat(times: int):
"""Decorator factory that calls the decorated function multiple times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello(name: str) -> str:
"""Print and return a greeting."""
message = f"Hello, {name}!"
print(message)
return message
say_hello("Alice")
The three layers serve different purposes:
repeat(times)receives the decorator arguments and returnsdecoratordecorator(func)receives the function being decorated and returnswrapperwrapper(*args, **kwargs)runs when the decorated function is called
Writing a timing decorator¶
A practical decorator that measures how long a function takes to execute.
import functools
import time
def timer(func):
"""Decorator that prints the execution time of a function."""
@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} seconds")
return result
return wrapper
@timer
def slow_sum(n: int) -> int:
"""Sum numbers from 1 to n with a deliberate pause."""
time.sleep(0.1)
return sum(range(1, n + 1))
slow_sum(1000)
Writing a retry decorator¶
This decorator automatically retries a function when it raises an exception, which is useful for network requests and other unreliable operations.
import functools
import time
def retry(max_attempts: int = 3, delay: float = 0.1):
"""Decorator factory that retries a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
call_count = 0
@retry(max_attempts=3, delay=0.05)
def flaky_operation() -> str:
"""Simulate an operation that fails twice, then succeeds."""
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Server unavailable")
return "Success"
result = flaky_operation()
print(f"Final result: {result}")
Writing a caching decorator¶
A memoisation decorator stores the return values of previous calls and returns the cached result when the same arguments appear again.
import functools
def memoise(func):
"""Decorator that caches return values based on arguments."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args in cache:
print(f" Cache hit for {func.__name__}{args}")
return cache[args]
print(f" Computing {func.__name__}{args}")
result = func(*args)
cache[args] = result
return result
return wrapper
@memoise
def fibonacci(n: int) -> int:
"""Return the nth Fibonacci number."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(f"fibonacci(6) = {fibonacci(6)}")
For production code, use functools.lru_cache instead of writing your own caching decorator. It provides the same functionality with additional features such as a maximum cache size.
import functools
@functools.lru_cache(maxsize=128)
def fibonacci_cached(n: int) -> int:
"""Return the nth Fibonacci number using built-in caching."""
if n < 2:
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
print(f"fibonacci_cached(30) = {fibonacci_cached(30)}")
print(f"Cache info: {fibonacci_cached.cache_info()}")
Decorating functions with any signature¶
The *args and **kwargs pattern in the wrapper function ensures your decorator works with functions that accept any combination of positional and keyword arguments.
import functools
def debug(func):
"""Decorator that prints the arguments and return value of a function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
print(f"{func.__name__}({signature})")
result = func(*args, **kwargs)
print(f" -> {result!r}")
return result
return wrapper
@debug
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
@debug
def calculate_total(*prices: float, discount: float = 0.0) -> float:
total = sum(prices)
return total * (1 - discount)
greet("Alice")
greet("Bob", greeting="Hi")
calculate_total(10.0, 20.0, 30.0, discount=0.1)
Summary¶
The key patterns for creating decorators are as follows:
- A basic decorator takes a function, defines a
wrapperthat adds behaviour, and returns the wrapper - Always use
@functools.wraps(func)to preserve the decorated function's name and docstring - Use a decorator factory (three-layer nesting) when your decorator needs configuration arguments
- Use
*argsand**kwargsin the wrapper to support functions with any signature - For caching, prefer
functools.lru_cacheover a hand-written memoisation decorator