Decorators are one of Python's most powerful features, yet many developers only scratch the surface. Let's go from basics to production-ready patterns.
What's Really Happening
A decorator is just a function that takes a function and returns a function. The @ syntax is syntactic sugar:
@my_decorator
def greet():
pass
# Is exactly the same as:
def greet():
pass
greet = my_decorator(greet)
Building Your First 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(1)
return "done"
slow_function() # slow_function took 1.0012s
Key detail: always use @functools.wraps(func). Without it, your decorated function loses its __name__, __doc__, and other metadata.
Decorators with Arguments
This is where people get confused. You need a decorator factory — a function that returns a decorator:
def retry(max_attempts=3, delay=1):
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 in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=5, delay=2)
def fetch_data(url):
# might fail due to network issues
import urllib.request
return urllib.request.urlopen(url).read()
Class-Based Decorators
For complex state management, use a class:
class RateLimit:
def __init__(self, calls_per_second=1):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - self.last_call
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_call = time.time()
return func(*args, **kwargs)
return wrapper
@RateLimit(calls_per_second=2)
def call_api(endpoint):
print(f"Calling {endpoint}")
Stacking Decorators
Decorators apply bottom-up:
@timer
@retry(max_attempts=3)
def process():
pass
# Equivalent to: timer(retry(max_attempts=3)(process))
Real-World Pattern: Caching with Expiry
def cache(ttl_seconds=300):
def decorator(func):
_cache = {}
@functools.wraps(func)
def wrapper(*args):
now = time.time()
if args in _cache:
result, timestamp = _cache[args]
if now - timestamp < ttl_seconds:
return result
result = func(*args)
_cache[args] = (result, now)
return result
return wrapper
return decorator
@cache(ttl_seconds=60)
def get_user(user_id):
# expensive database call
return db.query(f"SELECT * FROM users WHERE id = {user_id}")
Real-World Pattern: Access Control
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
if not hasattr(request, 'user') or role not in request.user.roles:
raise PermissionError(f"Role '{role}' required")
return func(request, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(request, user_id):
# only admins can do this
pass
Key Takeaways
- Always use
@functools.wrapsto preserve function metadata - Decorator factories (decorators with arguments) need three levels of nesting
- Class-based decorators are great for stateful behavior
- Stacking order matters — bottom decorator applies first
- Keep decorators focused on a single concern
Decorators are the backbone of frameworks like Flask, FastAPI, and pytest. Master them and you'll understand how those frameworks work under the hood.
🚀 Level up your AI workflow! Check out my AI Developer Mega Prompt Pack — 80 battle-tested prompts for developers. $9.99
Top comments (0)