Decorators — Wrapping Functions to Add Behaviour

A decorator is a callable that takes a function as input and returns a modified version of that function. The @decorator syntax is syntactic sugar — @my_decorator above a function definition is exactly equivalent to func = my_decorator(func). Decorators are how Python implements cross-cutting concerns cleanly: authentication, logging, timing, caching, and validation logic that applies to many functions can be extracted into a decorator and applied with a single line. FastAPI’s @app.get("/path") is a decorator — understanding how decorators work from first principles explains how FastAPI registers routes.

Decorators from First Principles

import functools

# ── Step 1: a function that takes a function ───────────────────────────────────
def my_decorator(func):
    # func is the function being decorated
    def wrapper(*args, **kwargs):
        print(f"Before calling {func.__name__}")
        result = func(*args, **kwargs)        # call the original function
        print(f"After calling {func.__name__}")
        return result
    return wrapper   # return the wrapper function

# ── Step 2: apply manually (what @ does automatically) ────────────────────────
def greet(name):
    print(f"Hello, {name}!")

greet = my_decorator(greet)   # same as @my_decorator above greet
greet("Alice")
# Before calling greet
# Hello, Alice!
# After calling greet

# ── Step 3: use @ syntax ──────────────────────────────────────────────────────
@my_decorator
def greet(name):
    print(f"Hello, {name}!")

# Identical to the manual application above
greet("Bob")

# ── Step 4: preserve the wrapped function's metadata with functools.wraps ─────
def better_decorator(func):
    @functools.wraps(func)   # ALWAYS use this — copies __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@better_decorator
def important_function():
    """This function does something important."""
    pass

print(important_function.__name__)   # "important_function" (not "wrapper")
print(important_function.__doc__)    # "This function does something important."
Note: Always use @functools.wraps(func) inside every decorator you write. Without it, the wrapper function replaces the decorated function’s metadata — __name__ becomes "wrapper", __doc__ becomes None. This breaks FastAPI’s auto-generated documentation (which reads the route handler’s docstring), confuses debuggers, and makes stack traces harder to read. It costs one line and prevents many headaches.
Tip: The *args, **kwargs pattern in the wrapper function is essential — it passes all positional and keyword arguments through to the original function unchanged. If you use specific parameter names instead (e.g. def wrapper(name)), the decorator only works for functions that accept exactly that parameter. Using *args, **kwargs makes the decorator generic and applicable to any function.
Warning: A common mistake is calling the function when applying the decorator: @my_decorator() with parentheses. Without parentheses, @my_decorator passes the function to my_decorator. With parentheses, @my_decorator() calls my_decorator() first (expecting it to return a decorator), then passes the function to that returned decorator. These are different patterns — using the wrong one causes a TypeError.

Decorators with Arguments

import functools

# A decorator with arguments needs an extra level of nesting
# Level 1: the decorator factory — accepts the arguments
# Level 2: the actual decorator — accepts the function
# Level 3: the wrapper — replaces the function

def repeat(times: int):
    """Decorator factory: repeat the function 'times' times."""
    def decorator(func):               # Level 2: actual decorator
        @functools.wraps(func)
        def wrapper(*args, **kwargs):  # Level 3: wrapper
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator   # Level 1 returns Level 2

@repeat(3)
def say_hello(name: str):
    print(f"Hello, {name}!")

say_hello("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

# @repeat(3) is equivalent to:
# say_hello = repeat(3)(say_hello)
#           = decorator(say_hello)
#           = wrapper  (returned from decorator)

# ── Real example: rate limiter decorator ──────────────────────────────────────
def require_role(*allowed_roles: str):
    """Decorator factory: restrict access to users with specific roles."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            user = kwargs.get("current_user") or (args[0] if args else None)
            if user is None or user.role not in allowed_roles:
                raise PermissionError(f"Role must be one of: {allowed_roles}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin", "editor")
def delete_post(post_id: int, current_user):
    print(f"Post {post_id} deleted by {current_user.name}")

Async Decorators — For FastAPI

import functools
import asyncio

def async_timer(func):
    """Decorator that works with both sync and async functions."""
    @functools.wraps(func)
    async def async_wrapper(*args, **kwargs):
        import time
        start  = time.perf_counter()
        result = await func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result

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

    # Return the appropriate wrapper based on whether func is async
    if asyncio.iscoroutinefunction(func):
        return async_wrapper
    return sync_wrapper

@async_timer
async def fetch_data(url: str):
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return response.json()

@async_timer
def process_data(data: list):
    return [item["id"] for item in data]

How FastAPI Route Decorators Work

# FastAPI's @app.get() is a decorator factory
# Understanding this helps you understand everything FastAPI does

from fastapi import FastAPI

app = FastAPI()

# @app.get("/posts") is equivalent to:
# get_posts = app.get("/posts")(get_posts)
# i.e., app.get("/posts") returns a decorator
# that decorator is then applied to get_posts

@app.get("/posts")
async def get_posts():
    return {"posts": []}

# app.get() is a method on FastAPI that:
# 1. Accepts the path as an argument
# 2. Returns a decorator function
# 3. The decorator registers the route handler with the path and HTTP method
# 4. Returns the original function unchanged (or a wrapped version)

# You can even apply the decorator manually if needed:
async def get_post(post_id: int):
    return {"id": post_id}

# Equivalent to @app.get("/posts/{post_id}")
app.get("/posts/{post_id}")(get_post)

Common Mistakes

Mistake 1 — Forgetting @functools.wraps

❌ Wrong — wrapped function loses its identity:

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

@log
def greet(): pass
print(greet.__name__)   # "wrapper" — not "greet"!

✅ Correct:

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

Mistake 2 — Using specific parameters in wrapper instead of *args/**kwargs

❌ Wrong — decorator only works for functions with that exact signature:

def log(func):
    def wrapper(name):   # only works for functions taking exactly (name,)!
        return func(name)
    return wrapper

✅ Correct — generic wrapper:

def log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):   # ✓ works for any function signature
        return func(*args, **kwargs)
    return wrapper

Mistake 3 — Forgetting to return the result from the wrapper

❌ Wrong — wrapped function always returns None:

def log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)   # called but result discarded!
    return wrapper

✅ Correct — return the result:

def wrapper(*args, **kwargs):
    result = func(*args, **kwargs)
    return result   # ✓

Quick Reference

Pattern Code
Basic decorator def dec(func): @wraps(func) def w(*a, **kw): ... return w
Apply decorator @dec above function, or func = dec(func)
Decorator with args Extra outer factory function that accepts args
Preserve metadata @functools.wraps(func) inside decorator
Async decorator Wrapper must be async def for async functions
FastAPI route @app.get("/path") is a decorator factory

🧠 Test Yourself

You apply @my_decorator to a function. Later you call help(my_function) and see “wrapper” as the function name and no docstring. What went wrong and how do you fix it?