The Complete Guide to Python Decorators — From Functions to Classes

What Is a Decorator?

A decorator is a powerful Python pattern that adds functionality to an existing function or class without modifying it. You use it by placing the @ symbol above a function, and internally it is a higher-order function that takes a function as an argument and returns a new function.

It cleanly separates repetitive cross-cutting concerns such as logging, authentication checks, caching, and performance measurement. This article walks through how decorators work and practical patterns step by step.

How It Works — Functions Are First-Class Objects

In Python, functions are first-class objects. They can be assigned to variables, passed as arguments, and used as return values. Decorators leverage this characteristic.

# Pattern: take a function as an argument and return a new function
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} called")    # Logic before the original function
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} finished")   # Logic after the original function
        return result
    return wrapper

@my_decorator
def greet(name):
    """Returns a greeting message."""
    return f"Hello, {name}!"

# Execution
print(greet("Alice"))
# [LOG] greet called
# [LOG] greet finished
# Hello, Alice!

@my_decorator is equivalent to greet = my_decorator(greet). The original greet function is replaced by the wrapper function, and logging logic is added before and after the call.

ComponentRole
my_decoratorDecorator function — creates a wrapper around the original function
wrapperWrapper function — the function that actually gets called
funcOriginal function — called inside the wrapper
*args, **kwargsPasses all arguments to the original function as-is

The Importance of functools.wraps

When a decorator is applied, the original function’s metadata (__name__, __doc__, etc.) gets overwritten with the wrapper’s metadata. functools.wraps solves this problem.

import functools

def my_decorator(func):
    @functools.wraps(func)  # Preserves the original function's metadata
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} called")
        result = func(*args, **kwargs)
        return result
    return wrapper

@my_decorator
def greet(name):
    """Returns a greeting message."""
    return f"Hello, {name}!"

# With wraps applied, metadata is preserved
print(greet.__name__)  # greet (without wraps, this would be "wrapper")
print(greet.__doc__)   # Returns a greeting message.

If you omit @functools.wraps(func), all function names appear as wrapper during debugging, making tracing difficult. It must always be used in production code.

Decorators with Arguments

To pass configuration values to a decorator itself, you need a triple-nested function structure. The outer function accepts the arguments and returns the actual decorator.

import functools
import time

def retry(max_attempts=3, delay=1.0):
    """Decorator that retries on failure"""
    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 Exception as e:
                    last_exception = e
                    print(f"[RETRY] {func.__name__} failed "
                          f"({attempt}/{max_attempts}): {e}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise last_exception  # Raise the last exception if all attempts fail
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    """Fetches data from an external API."""
    import random
    if random.random() < 0.7:  # 70% chance of failure
        raise ConnectionError("Network error")
    return {"status": "ok"}

# Execution (automatically retries on failure)
# [RETRY] fetch_data failed (1/3): Network error
# [RETRY] fetch_data failed (2/3): Network error
# {'status': 'ok'}  <- Succeeds on the 3rd attempt

@retry(max_attempts=3) is equivalent to fetch_data = retry(max_attempts=3)(fetch_data). retry() is called first and returns decorator, which then wraps fetch_data.

Practical Pattern — Execution Time Measurement

import functools
import time

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

@timer
def heavy_computation(n):
    """Simulates a heavy computation"""
    return sum(i * i for i in range(n))

result = heavy_computation(1_000_000)
# [TIMER] heavy_computation: 0.0523s

Class-Based Decorators

A class that implements the __call__ method can also be used as a decorator. This is useful when you need to maintain state across calls.

import functools

class CountCalls:
    """Class decorator that tracks function call count"""

    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"[COUNT] {self.func.__name__}: "
              f"call #{self.call_count}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(name):
    return f"Hello, {name}!"

say_hello("Alice")  # [COUNT] say_hello: call #1
say_hello("Bob")    # [COUNT] say_hello: call #2
print(say_hello.call_count)  # 2

Class decorators can naturally maintain state across calls, like call_count. With function decorators, you would need closure variables or function attributes, making the code more complex.

Combining Decorators (Stacking)

Multiple decorators can be applied simultaneously. They are applied from bottom to top and executed from top to bottom.

@timer          # Applied 3rd, executed 1st
@retry(max_attempts=2)  # Applied 2nd, executed 2nd
@CountCalls     # Applied 1st, executed 3rd
def unstable_api_call():
    import random
    if random.random() < 0.5:
        raise ConnectionError("Timeout")
    return "Success"

# Execution order: timer -> retry -> CountCalls -> original function

The application order of the decorator stack is bottom to top, while the execution order is top to bottom. This is equivalent to unstable_api_call = timer(retry(max_attempts=2)(CountCalls(unstable_api_call))).

Summary

Decorators are a core Python pattern for cleanly separating repetitive cross-cutting concerns.

  • Basic structure: A higher-order function that takes a function and returns a wrapper
  • functools.wraps: Always use it to preserve the original function’s metadata
  • Decorators with arguments: Triple-nested function structure (decorator factory)
  • Class decorators: Use when state needs to be maintained across calls
  • Practical uses: Widely used for logging, caching, retries, permission checks, performance measurement, and more
  • Stack order: Applied bottom to top, executed top to bottom

When used effectively, decorators clearly separate business logic from auxiliary functionality, greatly improving maintainability.

Was this article helpful?