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:

  1. Always use functools.wraps to preserve function metadata
  2. Keep decorators focused on a single responsibility
  3. Document whether your decorator works with sync, async, or both
  4. Consider performance—decorators add function call overhead
  5. Use class-based decorators when you need state or complex logic
  6. 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.