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.

Code on a laptop screen
The moment decorators finally made sense ( like actually made sense, not just "I'll pretend I understand" )

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
    pass
Laptop with code
Retry logic in a decorator instead of scattered across your codebase ( yes please )

The 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
    pass

The 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
    pass

Order 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.