Decorators confused me for way longer than I care to admit. I'd copy-paste @staticmethod and @classmethod without really understanding what was happening. Then I actually needed to write one, and everything clicked. Here are three decorator patterns I use constantly now.
1. The Timing Decorator
I use this one almost daily. When a script takes too long and I need to figure out which function is the culprit, I slap @timing on it and move on.
import time
import functools
def timing(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
@timing
def process_data(items):
# your slow code here
time.sleep(2)
return len(items)Notice functools.wraps. Skip it and your function metadata ( name, docstring ) gets replaced by the wrapper's. I learned this the hard way when debug output showed wrapper instead of the actual function name. Not fun.
2. The Retry Decorator
Network calls fail. APIs timeout. Rate limits hit. Instead of wrapping every request in a try/except loop, I use this:
import functools
import time
def retry(max_attempts=3, delay=1, backoff=2, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempt = 1
current_delay = delay
while attempt <= max_attempts:
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
print(f"{func.__name__} failed ({e}), retrying in {current_delay}s...")
time.sleep(current_delay)
current_delay *= backoff
attempt += 1
return wrapper
return decorator
@retry(max_attempts=3, delay=2, exceptions=(ConnectionError, TimeoutError))
def fetch_api(url):
# flaky API call here
passThe backoff parameter is key. Linear retries hammer a struggling service. Exponential backoff gives it breathing room. I set backoff=2 by default and adjust from there.
3. The Cache-with-TTL Decorator
Python's built-in @lru_cache is great, but it doesn't expire entries. For API responses or database queries that go stale, I need a TTL. Here's my go-to:
import functools
import time
def cached(ttl_seconds=300):
def decorator(func):
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
if key in cache:
value, timestamp = cache[key]
if now - timestamp < ttl_seconds:
return value
result = func(*args, **kwargs)
cache[key] = (result, now)
return result
wrapper.cache = cache # for manual invalidation
return wrapper
return decorator
@cached(ttl_seconds=60)
def get_exchange_rate(currency):
# call some API
passThe wrapper.cache = cache line is there so I can manually clear the cache when needed ( get_exchange_rate.cache.clear() ). Useful when you know data changed and you don't want to wait for the TTL.
Why These Three
All three solve the same class of problem: cross-cutting concerns that don't belong inside the function itself. Timing, retrying, caching — these are orthogonal to what the function actually does. Shoving them into the function body makes the code harder to read and harder to test. Pulling them into decorators keeps things clean.
And yeah, you can stack them:
@timing
@retry(max_attempts=3)
@cached(ttl_seconds=120)
def get_user_data(user_id):
# fetch from API
passOrder matters. The decorator closest to the function runs first, so this caches first, then retries if the cache miss causes an error, then times the whole thing. That's usually what I want.
Decorators aren't magic. They're just functions that take a function and return a function. Once you internalize that, the @syntax is just sugar. Write the wrapper, return it, and move on.
Thank you.