Decorators look like magic. They're not. Here's how they work.

What's a Decorator?

A decorator is a function that takes a function and returns a new function:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper
 
@my_decorator
def say_hello():
    print("Hello!")
 
say_hello()
# Before
# Hello!
# After

The @decorator syntax is just sugar for:

say_hello = my_decorator(say_hello)

A Practical Example

import time
 
def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.2f}s")
        return result
    return wrapper
 
@timer
def slow_function():
    time.sleep(1)
 
slow_function()
# slow_function took 1.00s

Preserving Metadata

There's a problem:

@timer
def my_func():
    """My docstring."""
    pass
 
print(my_func.__name__)  # wrapper, not my_func!
print(my_func.__doc__)   # None!

Fix with functools.wraps:

from functools import wraps
 
def timer(func):
    @wraps(func)  # Preserves name, docstring, etc.
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

Always use @wraps. It's one line and saves headaches.

Decorators with Arguments

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 greet():
    print("Hello!")
 
greet()
# Hello!
# Hello!
# Hello!

The outer function takes the arguments, returns the actual decorator.

Common Use Cases

Logging

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Authentication

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():
    ...

Caching

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 use the built-in:

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

Retry Logic

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=2)
def flaky_api_call():
    ...

Stacking Decorators

@log_calls
@timer
@cache
def compute(x):
    return x * 2

Applied bottom-up: cache first, then timer, then log_calls.

Class Decorators

Decorate a whole class:

def add_repr(cls):
    def __repr__(self):
        attrs = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls
 
@add_repr
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
 
print(User("Owen", "owen@example.com"))
# User(name='Owen', email='owen@example.com')

Method Decorators

For methods, remember self:

def log_method(func):
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"{self.__class__.__name__}.{func.__name__} called")
        return func(self, *args, **kwargs)
    return wrapper
 
class Calculator:
    @log_method
    def add(self, a, b):
        return a + b

Built-in Decorators

Python includes several:

class MyClass:
    @property
    def name(self):
        return self._name
    
    @staticmethod
    def utility():
        pass
    
    @classmethod
    def from_string(cls, s):
        return cls(s)
 
from functools import lru_cache, cached_property
from dataclasses import dataclass
 
@dataclass
class Point:
    x: int
    y: int

When to Use

Use decorators for cross-cutting concerns:

  • Logging
  • Timing
  • Caching
  • Authentication/authorization
  • Validation
  • Retry logic

Don't use decorators when:

  • Simple inheritance works better
  • The logic is specific to one function
  • It makes code harder to understand

Decorators should be reusable across multiple functions.

React to this post: