Python Decorators Masterclass: From Basics to Production Patterns
Decorators are one of Python's most powerful features -- used in every major framework. Master them completely, from first principles to real-world patterns like caching, retries, and rate limiting.
How Decorators Work Internally
A decorator is simply a callable that takes a function, wraps it, and returns a new callable. The @ syntax is pure syntactic sugar.
# These two are 100% identical
@my_decorator
def greet():
print("Hello")
# is exactly the same as:
def greet():
print("Hello")
greet = my_decorator(greet)
Building one from scratch:
def timer(func):
import time
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 expensive_operation(n):
return sum(i**2 for i in range(n))
expensive_operation(1_000_000)
# expensive_operation took 0.1823s
functools.wraps -- Why It Matters
Without functools.wraps, your decorated function loses its identity -- name, docstring, signature all get replaced by the wrapper's. This breaks introspection, docs, and debugging.
from functools import wraps
def timer(func):
@wraps(func) # <- preserves __name__, __doc__, __annotations__, __module__
def wrapper(*args, **kwargs):
...
return wrapper
@timer
def process():
"""Processes data."""
pass
print(process.__name__) # "process" [YES] (without wraps: "wrapper" [NO])
print(process.__doc__) # "Processes data." [YES]
Decorators with Arguments
To pass arguments to a decorator, you need a third level of nesting -- a factory function that returns the actual decorator.
from functools import wraps
import time
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""Retry a function on failure with configurable attempts and delay."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
print(f"Attempt {attempt}/{max_attempts} failed: {e}")
if attempt < max_attempts:
time.sleep(delay * attempt) # exponential-ish backoff
raise last_exc
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_api_data(url: str) -> dict:
import requests
resp = requests.get(url, timeout=5)
resp.raise_for_status()
return resp.json()
Class-Based Decorators
Use a class decorator when you need to maintain state between calls (e.g., call counts, caches).
from functools import wraps
class RateLimiter:
"""Allow at most `calls` per `period` seconds."""
def __init__(self, calls: int, period: float):
self.calls = calls
self.period = period
self.timestamps: list[float] = []
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
import time
now = time.monotonic()
# Remove timestamps outside the window
self.timestamps = [t for t in self.timestamps if now - t < self.period]
if len(self.timestamps) >= self.calls:
sleep_for = self.period - (now - self.timestamps[0])
time.sleep(max(0, sleep_for))
self.timestamps.append(time.monotonic())
return func(*args, **kwargs)
return wrapper
@RateLimiter(calls=5, period=1.0)
def call_api(endpoint: str):
print(f"Calling {endpoint}")
# Will automatically throttle to 5 calls/second
Stacking Multiple Decorators
Decorators apply bottom-up. The decorator closest to the function executes first.
@timer # applied second (outer)
@retry(3) # applied first (inner)
def fetch(url):
...
# Equivalent to: timer(retry(3)(fetch))
10 Production-Ready Decorator Recipes
1. Memoize / LRU Cache
from functools import lru_cache
@lru_cache(maxsize=256)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Built-in, thread-safe, O(1) lookup
2. Singleton
def singleton(cls):
instances = {}
@wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self):
self.conn = create_connection()
3. Type-Check Arguments
def validate_types(func):
@wraps(func)
def wrapper(*args, **kwargs):
hints = func.__annotations__
bound = func.__code__.co_varnames
for i, (arg, name) in enumerate(zip(args, bound)):
if name in hints and not isinstance(arg, hints[name]):
raise TypeError(f"{name}: expected {hints[name].__name__}, got {type(arg).__name__}")
return func(*args, **kwargs)
return wrapper
@validate_types
def add(x: int, y: int) -> int:
return x + y
add(1, 2) # OK
add(1, "2") # TypeError: y: expected int, got str
4. Log Every Call
import logging, functools
def log_calls(logger=None):
def decorator(func):
_log = logger or logging.getLogger(func.__module__)
@functools.wraps(func)
def wrapper(*args, **kwargs):
_log.debug(f"CALL {func.__qualname__}({args!r}, {kwargs!r})")
result = func(*args, **kwargs)
_log.debug(f"RETURN {func.__qualname__} -> {result!r}")
return result
return wrapper
return decorator
5. Deprecation Warning
import warnings, functools
def deprecated(reason: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(
f"{func.__name__} is deprecated: {reason}",
DeprecationWarning, stacklevel=2
)
return func(*args, **kwargs)
return wrapper
return decorator
@deprecated("Use new_function() instead")
def old_function(): ...
Async Decorators
import asyncio, time, functools
def async_timer(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter()
result = await func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
return result
return wrapper
def async_retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return await func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
await asyncio.sleep(delay * attempt)
return wrapper
return decorator
@async_timer
@async_retry(max_attempts=3, delay=0.5)
async def fetch(url: str) -> dict:
async with aiohttp.ClientSession() as s:
async with s.get(url) as r:
return await r.json()
Key Takeaways: Always use @functools.wraps. Use class-based decorators for stateful behaviour. Layer decorators carefully -- order matters. Build your production toolkit: retry, rate-limit, cache, log, validate.
PythonDecoratorsDesign PatternsfunctoolsProduction PythonAdvanced Python
0 Comments