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."
@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.*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.@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 |