Python Decorators Explained: A Complete Guide with Real Examples

· 10 min read

Introduction

You have probably seen code like this:

@login_required
def dashboard(request):
    return render(request, "dashboard.html")

That @login_required line is a decorator. In one line, it adds authentication checking to any function — without touching the function’s own code.

Decorators are one of the most powerful and widely used Python features. They appear everywhere: Flask and Django route handlers, caching, logging, rate limiting, retry logic, test utilities, and more.

But most explanations jump straight to decorator syntax without explaining the underlying mechanics. This guide builds from first principles — closures, higher-order functions, and functools.wraps — so the @ syntax actually makes sense.

By the end, you will understand:

  • Why decorators exist and what problem they solve
  • How closures make decorators possible
  • How to write decorators from scratch
  • How to pass arguments to decorators
  • How to stack multiple decorators
  • Real-world decorator patterns used in production code

All examples are tested on Python 3.12.


The Problem Decorators Solve

Suppose you have several functions and you want to log how long each one takes to run:

import time

def fetch_data():
    start = time.time()
    # actual work
    time.sleep(0.1)
    end = time.time()
    print(f"fetch_data took {end - start:.3f}s")

def process_data():
    start = time.time()
    # actual work
    time.sleep(0.2)
    end = time.time()
    print(f"process_data took {end - start:.3f}s")

This approach works, but it has serious problems. The timing logic is duplicated in every function. If you want to change the format, you have to update every function. And the timing code is mixed in with the actual business logic, making both harder to read.

Decorators solve this by letting you wrap a function’s behavior without modifying the function itself.


Prerequisites: Functions Are Objects

Before writing decorators, you need to understand one key fact: in Python, functions are objects.

This means you can assign functions to variables, store them in lists, and pass them as arguments to other functions:

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

# Assign to a variable
say_hello = greet
print(say_hello("Alice"))

# Pass as an argument
def call_twice(func, value):
    func(value)
    func(value)

call_twice(print, "hello")

Expected output:

Hello, Alice
hello
hello

Functions that accept other functions as arguments, or return functions as results, are called higher-order functions. Decorators are built on this concept.


Prerequisites: Closures

A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished executing:

def make_multiplier(factor):
    def multiply(x):
        return x * factor  # factor is captured from the outer scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))
print(triple(5))

Expected output:

10
15

make_multiplier returns a new function each time it is called. Each returned function remembers its own factor value. This is a closure — multiply closes over the variable factor from the enclosing scope.

Understanding closures is the key to understanding how decorators work internally.


Building a Decorator from Scratch

A decorator is a function that takes a function as input and returns a new function that wraps the original.

Here is the timing example rewritten as a decorator:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.3f}s")
        return result
    return wrapper

Now apply it manually:

def fetch_data():
    time.sleep(0.1)
    return "data"

fetch_data = timer(fetch_data)
result = fetch_data()

Expected output:

fetch_data took 0.100s

This is exactly what the @ syntax does — it is shorthand for fetch_data = timer(fetch_data).


The @ Syntax

The decorator syntax makes the wrapping explicit and readable:

@timer
def fetch_data():
    time.sleep(0.1)
    return "data"

@timer
def process_data():
    time.sleep(0.2)
    return "processed"

fetch_data()
process_data()

Expected output:

fetch_data took 0.100s
process_data took 0.201s

One decorator, applied to as many functions as needed, with no code duplication. The @timer line is equivalent to writing fetch_data = timer(fetch_data) after the function definition.


Why *args and **kwargs Matter

The wrapper function inside a decorator uses *args and **kwargs to accept any combination of positional and keyword arguments. This is essential — without it, the decorator would only work with functions that have a specific signature.

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)  # pass everything through
        end = time.time()
        print(f"{func.__name__} took {end - start:.3f}s")
        return result
    return wrapper

@timer
def add(x, y):
    return x + y

@timer
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}"

print(add(3, 4))
print(greet("Alice", greeting="Hi"))

Expected output:

add took 0.000s
7
greet took 0.000s
Hi, Alice

The decorator works correctly regardless of how many arguments the wrapped function takes.


Preserving Function Identity with functools.wraps

Without extra care, a decorator replaces the original function’s metadata with the wrapper’s metadata:

@timer
def fetch_data():
    """Fetches data from the server."""
    time.sleep(0.1)

print(fetch_data.__name__)
print(fetch_data.__doc__)

Expected output:

wrapper
None

The function’s name and docstring are gone — replaced by the wrapper’s. This breaks documentation tools, debuggers, and anything that inspects function metadata.

The fix is functools.wraps:

import functools
import time

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

@timer
def fetch_data():
    """Fetches data from the server."""
    time.sleep(0.1)

print(fetch_data.__name__)
print(fetch_data.__doc__)

Expected output:

fetch_data
Fetches data from the server.

Always use @functools.wraps(func) inside decorators. It is a one-line addition that prevents subtle bugs in production code.


Decorators with Arguments

Sometimes you want to configure a decorator — for example, specifying a retry count or a cache timeout. This requires one more layer of nesting:

import functools
import time

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

Usage:

@retry(max_attempts=3, delay=0.5)
def unstable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network error")
    return "success"

result = unstable_api_call()

Example output:

Attempt 1 failed: Network error
Attempt 2 failed: Network error
success

The three-layer structure works like this:

  • retry(max_attempts=3, delay=0.5) is called first and returns decorator
  • decorator(unstable_api_call) is called next and returns wrapper
  • wrapper is what actually runs when you call unstable_api_call()

Stacking Multiple Decorators

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

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold
@italic
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))

Expected output:

<b><i>Hello, Alice</i></b>

@bold is on the outside, @italic is on the inside. The function is first wrapped by italic, then that result is wrapped by bold. Reading from bottom to top matches the order of wrapping.


Class-Based Decorators

Decorators do not have to be functions. Any callable object works — including classes that implement __call__:

import functools

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"{self.func.__name__} has been called {self.count} time(s)")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()
say_hello()
say_hello()

Expected output:

say_hello has been called 1 time(s)
Hello!
say_hello has been called 2 time(s)
Hello!
say_hello has been called 3 time(s)
Hello!

Class-based decorators are useful when the decorator needs to maintain state between calls — like a call counter, a cache, or a rate limiter.


Real-World Decorator Patterns

Caching with functools.lru_cache

Python’s standard library includes a caching decorator:

import functools

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

print(fibonacci(50))
print(fibonacci.cache_info())

Expected output:

12586269025
CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)

Without lru_cache, computing fibonacci(50) would require billions of recursive calls. With it, each unique input is computed once and cached. The cache_info() method shows how many times the cache was hit versus missed.

Timing and Profiling

import functools
import time

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__}: {elapsed:.4f}s")
        return result
    return wrapper

@timer
def sort_large_list():
    data = list(range(1_000_000, 0, -1))
    return sorted(data)

sort_large_list()

Example output:

sort_large_list: 0.0842s

time.perf_counter() is more precise than time.time() for measuring short durations.

Logging

import functools
import logging

logging.basicConfig(level=logging.INFO)

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def add(x, y):
    return x + y

add(3, 4)

Example output:

INFO:root:Calling add with args=(3, 4), kwargs={}
INFO:root:add returned 7

Input Validation

import functools

def validate_positive(*param_names):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
            for name in param_names:
                if name in bound.arguments and bound.arguments[name] <= 0:
                    raise ValueError(f"Parameter '{name}' must be positive, got {bound.arguments[name]}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_positive("width", "height")
def create_rectangle(width, height):
    return width * height

print(create_rectangle(5, 3))

try:
    create_rectangle(-1, 3)
except ValueError as e:
    print(e)

Expected output:

15
Parameter 'width' must be positive, got -1

Rate Limiting

import functools
import time

def rate_limit(calls_per_second):
    min_interval = 1.0 / calls_per_second
    last_called = [0.0]

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            elapsed = time.time() - last_called[0]
            wait = min_interval - elapsed
            if wait > 0:
                time.sleep(wait)
            last_called[0] = time.time()
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limit(calls_per_second=2)
def api_call(endpoint):
    print(f"Calling {endpoint} at {time.time():.2f}")

for endpoint in ["/users", "/posts", "/comments"]:
    api_call(endpoint)

Example output:

Calling /users at 1715900000.12
Calling /posts at 1715900000.62
Calling /comments at 1715900001.13

Each call is spaced at least 0.5 seconds apart, enforcing the two-calls-per-second limit.

The retry and rate limiting patterns shown here connect naturally to proper error handling. The exception handling guide covers how to structure try/except blocks inside decorated functions, including retry logic with exponential backoff.


Common Mistakes

Forgetting to Return the Result

# Wrong — the wrapped function always returns None
def broken_timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        func(*args, **kwargs)  # result is discarded!
        print(f"took {time.time() - start:.3f}s")
    return wrapper

# Correct
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)  # capture the result
        print(f"took {time.time() - start:.3f}s")
        return result  # return it
    return wrapper

Calling the Decorator Instead of Applying It

# Wrong — timer() is called immediately, returns None
@timer()
def fetch_data():
    pass

# Correct — timer is applied as a decorator
@timer
def fetch_data():
    pass

This error is easy to make when switching between parameterized and non-parameterized decorators.

Forgetting functools.wraps

# Wrong — loses __name__, __doc__, and other metadata
def timer(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# Correct
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Quick Reference

PatternWhen to Use
Basic decoratorAdd behavior to any function without modifying it
@functools.wrapsAlways — preserves function metadata
*args, **kwargsAlways — makes the decorator work with any function signature
Decorator with argumentsWhen the decorator needs configuration
Stacked decoratorsWhen applying multiple independent behaviors
Class-based decoratorWhen the decorator needs to maintain state
@functools.lru_cacheCaching pure functions with repeated inputs

Wrap-Up

Decorators follow a consistent pattern once you see the underlying mechanics:

  1. A decorator is a function that takes a function and returns a function
  2. The inner wrapper function adds behavior before or after calling the original
  3. *args and **kwargs make the wrapper work with any function signature
  4. @functools.wraps(func) preserves the original function’s metadata
  5. Parameterized decorators add one more layer of nesting

The @ syntax is pure syntactic sugar — @timer above a function is exactly equivalent to func = timer(func) below it. Once that clicks, decorators stop feeling magical and start feeling mechanical.

For more on how Python executes function calls and manages scope — which connects directly to how closures work — see the deep dive on what happens when you run python script.py. For questions or future tutorial ideas, get in touch via the Contact page.