Decorator Pattern in Python: Function and Class Decorators
The decorator pattern is a structural design pattern that lets you attach new behaviors to objects by wrapping them in objects that contain those behaviors. In Python, this pattern gets first-class...
Key Insights
- Function decorators are closures that wrap callables, and understanding the three-level nesting pattern for parameterized decorators unlocks most advanced use cases.
- Class-based decorators using
__call__provide superior state management for complex scenarios like memoization, rate limiting, and call tracking. - Decorator execution order matters: decorators stack bottom-up but execute top-down, which directly impacts how you design authentication, logging, and caching layers.
Introduction to the Decorator Pattern
The decorator pattern is a structural design pattern that lets you attach new behaviors to objects by wrapping them in objects that contain those behaviors. In Python, this pattern gets first-class language support through the @ syntax, making it one of the most elegant implementations across any programming language.
The core idea is simple: wrap a function or class to extend its behavior without modifying its source code. This adheres to the open-closed principle—your code is open for extension but closed for modification. Python’s treatment of functions as first-class objects makes this natural. Functions can be passed as arguments, returned from other functions, and assigned to variables.
You’ve already used decorators if you’ve worked with @property, @staticmethod, or @dataclass. Understanding how to build your own transforms you from a decorator consumer to a decorator architect.
Function Decorators Fundamentals
A decorator is a function that takes a function as an argument and returns a new function. The @ syntax is pure syntactic sugar. These two approaches are identical:
@my_decorator
def my_function():
pass
# Equivalent to:
def my_function():
pass
my_function = my_decorator(my_function)
The decorator typically defines an inner wrapper function that adds behavior before or after calling the original. Here’s a timing decorator:
import time
import functools
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} seconds")
return result
return wrapper
@timer
def slow_operation(n):
"""Simulate a slow operation."""
time.sleep(n)
return n * 2
result = slow_operation(1) # Prints: slow_operation took 1.0012 seconds
Three critical details here. First, *args, **kwargs lets the wrapper accept any arguments and pass them through. Second, functools.wraps preserves the original function’s metadata (__name__, __doc__, etc.)—skip this and debugging becomes painful. Third, always return the result of calling the wrapped function.
A logging decorator demonstrates before/after patterns:
import functools
import logging
logging.basicConfig(level=logging.INFO)
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
except Exception as e:
logging.error(f"{func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
Decorators with Arguments
When you need configurable decorators, you add another layer of nesting. The outer function takes the decorator arguments and returns the actual decorator:
import functools
import time
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
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 exceptions as e:
last_exception = e
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
# Simulated unreliable network call
import random
if random.random() < 0.7:
raise ConnectionError("Network unstable")
return {"data": "success"}
The pattern is: retry(...) returns decorator, which takes func and returns wrapper. Here’s a rate limiter using the same structure:
import functools
import time
from collections import deque
def rate_limit(calls=10, period=60):
def decorator(func):
timestamps = deque()
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# Remove timestamps outside the window
while timestamps and timestamps[0] < now - period:
timestamps.popleft()
if len(timestamps) >= calls:
sleep_time = timestamps[0] - (now - period)
time.sleep(sleep_time)
timestamps.popleft()
timestamps.append(time.time())
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls=5, period=10)
def api_call(endpoint):
return f"Called {endpoint}"
Class-Based Decorators
When decorators need to maintain state or become complex, classes provide better organization. Implement __call__ to make instances callable:
import functools
class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
def reset(self):
self.count = 0
@CallCounter
def process_item(item):
return item.upper()
process_item("hello")
process_item("world")
print(process_item.count) # 2
process_item.reset()
Memoization benefits greatly from class-based implementation:
import functools
class Memoize:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.cache = {}
def __call__(self, *args):
if args not in self.cache:
self.cache[args] = self.func(*args)
return self.cache[args]
def cache_clear(self):
self.cache.clear()
def cache_info(self):
return {"size": len(self.cache), "keys": list(self.cache.keys())}
@Memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # Instant, thanks to memoization
print(fibonacci.cache_info()) # Shows cached values
Decorating Classes (Class Decorators)
Decorators can also wrap entire classes. The decorator receives the class and returns a modified version:
def singleton(cls):
instances = {}
@functools.wraps(cls, updated=[])
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, host="localhost"):
self.host = host
print(f"Connecting to {host}")
conn1 = DatabaseConnection() # Prints: Connecting to localhost
conn2 = DatabaseConnection() # No output, returns existing instance
print(conn1 is conn2) # True
Plugin registration is another powerful pattern:
class PluginRegistry:
plugins = {}
@classmethod
def register(cls, name):
def decorator(plugin_cls):
cls.plugins[name] = plugin_cls
return plugin_cls
return decorator
@classmethod
def get(cls, name):
return cls.plugins.get(name)
@PluginRegistry.register("json")
class JsonParser:
def parse(self, data):
import json
return json.loads(data)
@PluginRegistry.register("xml")
class XmlParser:
def parse(self, data):
# XML parsing logic
pass
parser = PluginRegistry.get("json")()
Stacking and Composition
Multiple decorators stack bottom-up but execute top-down. The decorator closest to the function wraps it first:
@decorator_a # Executes first (outermost)
@decorator_b # Executes second
@decorator_c # Wraps the function first (innermost)
def my_function():
pass
# Equivalent to:
my_function = decorator_a(decorator_b(decorator_c(my_function)))
Here’s a practical example with authentication, logging, and caching:
import functools
import time
def authenticate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Simulate auth check
user = kwargs.get("user")
if not user or not user.get("authenticated"):
raise PermissionError("Authentication required")
print(f"[AUTH] User {user.get('name')} authenticated")
return func(*args, **kwargs)
return wrapper
def log_request(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[LOG] Completed {func.__name__}")
return result
return wrapper
def cache_response(ttl=60):
def decorator(func):
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(sorted(kwargs.items()))
if key in cache:
value, timestamp = cache[key]
if time.time() - timestamp < ttl:
print("[CACHE] Hit")
return value
result = func(*args, **kwargs)
cache[key] = (result, time.time())
return result
return wrapper
return decorator
@authenticate # Checks auth first
@log_request # Then logs
@cache_response(ttl=300) # Then checks cache
def get_user_data(user_id, user=None):
return {"id": user_id, "data": "sensitive"}
Real-World Patterns and Best Practices
Flask-style route decorators demonstrate registration patterns:
class Router:
def __init__(self):
self.routes = {}
def route(self, path, methods=None):
methods = methods or ["GET"]
def decorator(func):
for method in methods:
key = (method, path)
self.routes[key] = func
return func
return decorator
def dispatch(self, method, path):
handler = self.routes.get((method, path))
if handler:
return handler()
raise ValueError(f"No route for {method} {path}")
app = Router()
@app.route("/users", methods=["GET", "POST"])
def users_handler():
return "Users endpoint"
Input validation decorators prevent bad data from reaching business logic:
import functools
def validate(**validators):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for param, validator in validators.items():
if param in kwargs:
value = kwargs[param]
if not validator(value):
raise ValueError(f"Invalid value for {param}: {value}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate(
email=lambda x: "@" in x and "." in x,
age=lambda x: isinstance(x, int) and 0 < x < 150
)
def create_user(email, age):
return {"email": email, "age": age}
Best practices to follow:
- Always use
functools.wrapsto preserve function metadata - Keep decorators focused on a single responsibility
- Document whether your decorator works with sync, async, or both
- Consider performance—decorators add function call overhead
- Use class-based decorators when you need state or complex logic
- Test decorated functions both with and without the decorator
When to avoid decorators: Don’t use them when the behavior modification is conditional based on runtime data that should be explicit in the call site, when the wrapping logic is a one-off, or when it obscures critical behavior that callers need to understand.
Decorators are powerful precisely because they’re invisible at the call site. Use that power responsibly.