What Is a Decorator?

A decorator is a function that wraps another function, adding behaviour before or after it runs — without modifying the original function's code. Decorators are used everywhere in Python: Flask uses them to define routes, Django uses them for login protection, and the standard library uses them for caching and property definitions.

At its core, a decorator is just a callable that takes a function and returns a new function.

The Building Block: Higher-Order Functions

Before decorators make sense, you need to understand that in Python, functions are first-class objects — they can be passed as arguments and returned from other functions:

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return str(result).upper()
    return wrapper

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

louder_greet = shout(greet)
print(louder_greet("alice"))  # HELLO, ALICE

The @ Syntax

The @decorator syntax is just syntactic sugar for the above pattern:

@shout
def greet(name):
    return f"hello, {name}"

# This is exactly equivalent to:
# greet = shout(greet)

Preserving the Original Function's Identity

Without extra care, wrapping a function hides its name and docstring. Always use functools.wraps to preserve function metadata:

import functools

def shout(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return str(result).upper()
    return wrapper

Real-World Use Case 1: Timing Functions

import time
import functools

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

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

Real-World Use Case 2: Retry Logic

import functools, time

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

@retry(times=3, delay=0.5)
def fetch_data(url):
    # ... network call that might fail
    pass

Notice how this decorator takes arguments — it's a "decorator factory" that returns the actual decorator.

Real-World Use Case 3: Caching with functools.lru_cache

Python's standard library includes a powerful caching decorator out of the box:

from functools import lru_cache

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

print(fibonacci(50))  # Instant, even for large n

Class-Based Decorators

You can also implement decorators as classes by defining __call__:

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

When to Use Decorators

  • Cross-cutting concerns: logging, timing, authentication, caching
  • Input/output transformation: type checking, serialization
  • Framework integration: registering routes, signal handlers

Decorators are one of Python's most expressive features. Once you understand the pattern, you'll start recognizing opportunities to use them everywhere — and your code will be cleaner for it.