Python Decorators: Mechanics and Practical Patterns

From basic decorator mechanics to parameterized decorators, class decorators, and functools.wraps, with practical code examples.

Introduction

Decorators are a powerful Python mechanism for adding functionality to existing functions or classes without modifying their behavior. Applied declaratively with the @ syntax, they are widely used for cross-cutting concerns such as logging, timing, retry logic, and caching.

This article covers decorator fundamentals through practical patterns with code examples.

Prerequisites: First-Class Functions and Closures

In Python, functions are objects that can be assigned to variables, passed to other functions, and returned from functions.

def greet(name):
    return f"Hello, {name}"

say_hello = greet  # Assign function to variable
print(say_hello("Alice"))  # "Hello, Alice"

A closure is an inner function that references variables from the enclosing function’s scope, retaining access even after the outer function returns.

def make_multiplier(factor):
    def multiplier(x):
        return x * factor  # References factor (closure)
    return multiplier

double = make_multiplier(2)
print(double(5))  # 10

Basic Decorator Pattern

A decorator is a “function that takes a function and returns a function.”

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper

@my_decorator
def add(a, b):
    return a + b

# @my_decorator is equivalent to add = my_decorator(add)
print(add(3, 4))
# Output:
# Calling add
# Finished add
# 7

The Importance of functools.wraps

Applying a decorator replaces the original function’s metadata (__name__, __doc__) with the wrapper’s. functools.wraps prevents this.

import functools

def my_decorator(func):
    @functools.wraps(func)  # Preserve original function metadata
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    """Add two numbers"""
    return a + b

print(add.__name__)  # "add" (without wraps: "wrapper")
print(add.__doc__)   # "Add two numbers"

Parameterized Decorators

When passing arguments to the decorator itself, a triple-nested structure is needed.

import functools

def repeat(n):
    """Decorator that repeats a function n times"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(n):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))  # ["Hello, Alice", "Hello, Alice", "Hello, Alice"]

Practical Patterns

Execution Timer

import functools
import time

def timer(func):
    """Decorator to measure execution time"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(1)
    return "done"

slow_function()  # "slow_function: 1.0012s"

Retry with Exponential Backoff

import functools
import time

def retry(max_attempts=3, base_delay=1.0):
    """Retry decorator with exponential backoff"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    delay = base_delay * (2 ** attempt)
                    print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, base_delay=0.5)
def unreliable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("API unavailable")
    return {"status": "ok"}

Simple Cache

import functools

def simple_cache(func):
    """Decorator to cache results"""
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@simple_cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # Impractical without caching

In practice, functools.lru_cache provides equivalent functionality:

@functools.lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

Class-Based Decorators

Classes implementing __call__ can also serve as decorators, useful when maintaining state.

import functools

class CountCalls:
    """Decorator that counts function invocations"""
    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)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()
say_hello()
print(f"Called {say_hello.count} times")  # "Called 2 times"

Stacking Decorators

Multiple decorators are applied bottom-up and executed top-down.

@timer
@retry(max_attempts=2)
def api_call():
    pass

# Equivalent: api_call = timer(retry(max_attempts=2)(api_call))
# Execution order: timer → retry → api_call

Built-in Decorators

Key standard library decorators:

DecoratorPurpose
@propertyAccess methods as properties
@staticmethodMethods without instance reference
@classmethodMethods receiving class as first argument
@functools.lru_cacheMemoization cache
@functools.wrapsPreserve metadata in decorators
@dataclasses.dataclassAuto-generate data classes

References