Python Decorators — The Magic Behind the @ Symbol
Advertisement
Introduction
Why This Matters
Decorators are one of Python's most powerful and widely-used features. Behind every @app.route() in Flask, every @router.get() in FastAPI, and every @login_required in Django is a decorator. They allow you to add behavior to functions or classes without modifying their source code, following the Open/Closed Principle.
Understanding decorators is essential not just for using popular frameworks but for writing your own reusable utilities: logging wrappers, authentication guards, retry logic, caching, rate limiting, and timing functions. They appear frequently in backend engineering interviews and code reviews as a signal of Python proficiency.
Decorators are just functions that accept and return functions — once you understand that, their syntax becomes intuitive.
How Decorators Work
A decorator is a callable that takes a function and returns a modified version of it.
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before function call")
result = func(*args, **kwargs)
print("After function call")
return result
return wrapper
@my_decorator
def say_hello(name: str) -> str:
return f"Hello, {name}!"
# @my_decorator is syntactic sugar for:
# say_hello = my_decorator(say_hello)
print(say_hello("Alice"))
# Before function call
# After function call
# Hello, Alice!Preserving Function Metadata with functools.wraps
Without @wraps, the decorated function loses its name and docstring.
import functools
def my_decorator(func):
@functools.wraps(func) # preserves __name__, __doc__, __annotations__
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
print(add.__name__) # 'add' (not 'wrapper')
print(add.__doc__) # 'Add two numbers.'Practical Decorators
Timing Decorator
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__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(0.5)
slow_function() # slow_function took 0.5001sRetry Decorator
import functools
import time
def retry(max_attempts: int = 3, delay: float = 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:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url: str) -> dict:
import httpx
response = httpx.get(url)
response.raise_for_status()
return response.json()Caching Decorator (with functools.lru_cache)
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # extremely fast due to caching
print(fibonacci.cache_info()) # CacheInfo(hits=48, misses=51, ...)Decorator Factories (Decorators with Arguments)
When a decorator needs parameters, you add an outer function.
import functools
def require_role(role: str):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
raise PermissionError(f"Requires role: {role}")
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user: dict, user_id: int) -> str:
return f"Deleted user {user_id}"
admin = {"name": "Alice", "role": "admin"}
print(delete_user(admin, 42)) # 'Deleted user 42'Class Decorators
You can also use classes as decorators by implementing __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"Call #{self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def greet(name: str) -> str:
return f"Hello, {name}!"
greet("Alice") # Call #1 to greet
greet("Bob") # Call #2 to greet
print(greet.count) # 2Common Mistakes
- Forgetting
@functools.wraps(func)— causes loss of function metadata - Calling the decorator instead of passing it:
@timer()vs@timer(without arguments) - Not returning the result of the inner function call
- Using mutable default arguments in decorator factories
- Stacking many decorators without understanding the order they apply (bottom-up)
Best Practices
- Always use
@functools.wraps(func)in your wrapper functions - Keep decorators focused on a single concern — logging, timing, auth, etc.
- Prefer
functools.lru_cacheover a hand-rolled caching decorator - Use decorator factories when you need configurable behavior
- Document what your decorator does in its docstring
Key Takeaways
- A decorator is a function that takes a function and returns a modified function
@decoratoris syntactic sugar forfunc = decorator(func)@functools.wraps(func)preserves the wrapped function's metadata- Decorator factories take arguments by adding an outer function layer
functools.lru_cacheis the standard library's caching decorator- Class-based decorators implement
__call__to be callable like functions - Popular frameworks like Flask, FastAPI, and Django use decorators extensively for routing and middleware
Advertisement