Decorators look like magic, but they're just functions. Here's how they work.

What's a Decorator?

A decorator wraps a function to modify its behavior:

@my_decorator
def my_function():
    pass
 
# Is equivalent to:
def my_function():
    pass
my_function = my_decorator(my_function)

A Simple Decorator

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper
 
@log_calls
def add(a, b):
    return a + b
 
add(2, 3)
# Calling add
# add returned 5

Preserving Function Metadata

Use functools.wraps to keep the original function's name and docstring:

from functools import wraps
 
def log_calls(func):
    @wraps(func)  # Preserves __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Always use @wraps in your decorators.

Decorators with Arguments

To pass arguments to a decorator, add another layer:

from functools import wraps
 
def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator
 
@repeat(3)
def say_hello():
    print("Hello!")
 
say_hello()
# Hello!
# Hello!
# Hello!

Common Patterns

Timing

import time
from functools import wraps
 
def timer(func):
    @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}s")
        return result
    return wrapper
 
@timer
def slow_function():
    time.sleep(1)

Caching

from functools import wraps
 
def cache(func):
    cached = {}
    
    @wraps(func)
    def wrapper(*args):
        if args not in cached:
            cached[args] = func(*args)
        return cached[args]
    return wrapper
 
@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

(Or just use @functools.lru_cache)

Retry

import time
from functools import wraps
 
def retry(max_attempts=3, delay=1):
    def decorator(func):
        @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
                    time.sleep(delay)
        return wrapper
    return decorator
 
@retry(max_attempts=3, delay=0.5)
def flaky_api_call():
    # Might fail sometimes
    pass

Authentication

from functools import wraps
 
def require_auth(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not current_user.is_authenticated:
            raise PermissionError("Login required")
        return func(*args, **kwargs)
    return wrapper
 
@require_auth
def delete_account():
    # Only authenticated users can do this
    pass

Validation

from functools import wraps
 
def validate_positive(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg < 0:
                raise ValueError("Arguments must be positive")
        return func(*args, **kwargs)
    return wrapper
 
@validate_positive
def calculate_area(width, height):
    return width * height

Class Decorators

Decorate classes too:

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 Database:
    def __init__(self):
        print("Connecting...")
 
db1 = Database()  # Connecting...
db2 = Database()  # No output - same instance
print(db1 is db2)  # True

Method Decorators

Works on methods the same way:

class API:
    @timer
    def fetch_data(self):
        # self is passed as first argument
        pass

Stacking Decorators

Apply multiple decorators (bottom-up):

@decorator_a
@decorator_b
@decorator_c
def my_function():
    pass
 
# Equivalent to:
# my_function = decorator_a(decorator_b(decorator_c(my_function)))

Built-in Decorators

Python provides several useful decorators:

class MyClass:
    @staticmethod
    def no_self():
        # No access to instance
        pass
    
    @classmethod
    def with_class(cls):
        # Access to class, not instance
        pass
    
    @property
    def computed(self):
        # Access like attribute
        return self._value
 
from functools import lru_cache
 
@lru_cache(maxsize=128)
def expensive_computation(n):
    # Results are cached
    pass
 
from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float

Decorator Template

Copy this for new decorators:

from functools import wraps
 
def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Before
        result = func(*args, **kwargs)
        # After
        return result
    return wrapper

With arguments:

from functools import wraps
 
def my_decorator(arg1, arg2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Use arg1, arg2 here
            return func(*args, **kwargs)
        return wrapper
    return decorator

Decorators are powerful once you understand the pattern: functions that take functions and return functions. Start with the template and build from there.

React to this post: