Python Decorators Explained

· 4 min read · Updated March 7, 2026 · intermediate
decorators functions functools metaprogramming patterns

A decorator is a function that takes another function as input and extends its behavior without explicitly modifying its code. Decorators let you wrap functions with reusable logic — like timing how long a function takes, logging its calls, or caching its results.

Basic Syntax

Python provides a special @ syntax for applying decorators. Before the syntax existed, you’d manually pass a function through a wrapper:

def my_function():
    return "hello"

# Old way — works but clunky
wrapped = my_decorator(my_function)

The @ syntax does the same thing more cleanly:

@my_decorator
def my_function():
    return "hello"

This is equivalent to my_function = my_decorator(my_function).

Your First Decorator

Here’s a decorator that prints a message before and after a function runs:

def trace(func):
    def wrapper():
        print("Starting")
        func()
        print("Finished")
    return wrapper

@trace
def say_hello():
    print("Hello, world!")

say_hello()
# Starting
# Hello, world!
# Finished

The outer trace function accepts the original function. The inner wrapper adds the extra behavior. trace returns the wrapper, which replaces the original function.

Preserving Function Metadata

The wrapper function hides the original function’s name, docstring, and other attributes:

def trace(func):
    def wrapper():
        print("Calling", func.__name__)
        return func()
    return wrapper

@trace
def greet(name):
    """Greets the user."""
    return f"Hello, {name}"

print(greet.__name__)   # wrapper — wrong!
print(greet.__doc__)    # None — wrong!

Python 3 added functools.wraps to copy the original function’s metadata to the wrapper:

import functools

def trace(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper

@trace
def greet(name):
    """Greets the user."""
    return f"Hello, {name}"

print(greet.__name__)   # greet — correct!
print(greet.__doc__)    # Greets the user. — correct!

Always use @functools.wraps(func) in your decorators. It costs nothing and prevents debugging headaches.

Decorators with Arguments

Sometimes you need to pass configuration to a decorator — like specifying how many times to retry a function, or which logger to use. This requires a third level of nesting.

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def greet(name):
    print(f"Hello, {name}")

greet("Alice")
# Hello, Alice
# Hello, Alice
# Hello, Alice

The flow is: repeat(times=3) returns decorator, which is then applied to greet.

Practical Example: Timing a Function

Here’s a decorator that measures how long a function takes to run:

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

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

slow_function()
# slow_function took 0.5012 seconds

Practical Example: Caching Results

The functools.lru_cache decorator caches a function’s return values based on its arguments:

import functools

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

print(fibonacci(100))  # Fast — cached!
print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)

Without caching, computing fibonacci(100) would take astronomically longer. With it, the function runs once per unique argument and returns the cached result thereafter.

Practical Example: Logging Function Calls

import functools
import logging

logging.basicConfig(level=logging.INFO)

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Calling {func.__name__}")
        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

@logged
def divide(a, b):
    return a / b

divide(10, 2)
# INFO: Calling divide
# INFO: divide returned 5.0

Stacking Decorators

You can apply multiple decorators to a single function. They stack from bottom to top:

@timer
@logged
def process_data(data):
    # ... processing ...
    return data

The process_data function is first wrapped by @logged, then that result is wrapped by @timer. The outermost decorator (closest to the function name) runs first.

When to Use Decorators

Decorators are the right choice when:

  • You want to add behavior to multiple functions without modifying each one
  • The behavior is orthogonal to the function’s main purpose (timing, logging, caching)
  • You need to configure the behavior at definition time

When Not to Use Decorators

Avoid decorators when:

  • The behavior is specific to one function — just write it inside the function
  • The wrapper changes the function’s signature in ways callers expect (document this clearly)
  • You’re overusing them — too many decorators make code hard to trace

Decorators are powerful, but they add a layer of indirection. Use them when the reuse benefit outweighs the complexity cost.

Class-Based Decorators

Decorators can also be classes. The class must implement call to be invoked when the decorated function is called:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        print(f"{self.func.__name__} called {self.calls} times")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    return "Hello"

say_hello()  # says Hello
say_hello()  # says Hello

Class-based decorators are useful when you need to maintain state across multiple calls.

Real-World Use Cases

Decorators appear everywhere in production Python code:

  • Flask routes: @app.route registers URL handlers
  • Django views: @login_required checks authentication
  • pytest: @pytest.fixture sets up test resources
  • FastAPI: @app.get and @app.post define API endpoints
  • Retry logic: Libraries like tenacity use decorators for retry policies

Understanding decorators helps you read and write code across the Python ecosystem.