Overview

A decorator is a function that takes another function and extends its behavior without modifying its source. They appear everywhere in Python: @property, @staticmethod, @app.route in Flask, @pytest.fixture in tests. This tutorial explains how they work and how to write your own.

Functions Are Objects

To understand decorators, first remember that in Python, functions are first-class objects.

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

say_hi = greet
print(say_hi("Alice"))   # Hello, Alice

def apply(func, value):
    return func(value)

print(apply(greet, "Bob"))  # Hello, Bob

Your First Decorator

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

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

print(greet("Alice"))   # HELLO, ALICE

The @uppercase line is syntactic sugar for:

greet = uppercase(greet)

Preserving Metadata with functools.wraps

Without care, the decorated function loses its name and docstring:

print(greet.__name__)   # wrapper — wrong

Fix it with functools.wraps:

import functools

def uppercase(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper
print(greet.__name__)   # greet

Always use functools.wraps. It preserves introspection, which matters for debugging, documentation, and frameworks that rely on function metadata.

Decorators with Arguments

To accept parameters, add another layer:

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

@repeat(3)
def say(msg):
    print(msg)

say("hello")

The call chain is: repeat(3) returns decorator, which receives say and returns wrapper.

Practical Use Case 1: Timing

import time
import functools

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

@timed
def slow_task():
    time.sleep(1)

slow_task()   # slow_task took 1.0012s

Practical Use Case 2: Retry on Failure

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

@retry(attempts=3, delay=0.5)
def fetch_data():
    # may raise a network error
    ...

Practical Use Case 3: Authentication in Flask

from functools import wraps
from flask import request, jsonify

def require_auth(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        token = request.headers.get("Authorization", "").removeprefix("Bearer ")
        if not token or not verify_token(token):
            return jsonify({"error": "Unauthorized"}), 401
        return func(*args, **kwargs)
    return wrapper

@app.route("/profile")
@require_auth
def profile():
    return jsonify({"name": "Alice"})

Practical Use Case 4: Caching

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

Python's standard library already provides functools.lru_cache and functools.cache, which are faster and thread-safe.

Stacking Decorators

@timed
@retry(attempts=2)
def fetch():
    ...

Decorators apply bottom-up: retry wraps fetch first, then timed wraps the retried function.

Class-Based Decorators

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} of {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hi():
    print("hi")

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

Common Pitfalls

Pitfall Symptom Fix
Missing functools.wraps Function name becomes wrapper Add the decorator
Mutable default in wrapper State leaks between calls Store state inside a closure or class
Decorator without parentheses when args are expected TypeError at definition time Use the three-level form
Decorating async functions with a sync wrapper Coroutine is not awaited Write an async wrapper with await