HiveCore Dev logo hivecore.dev

Python Decorators Explained With Real Code

// Python · HiveCore Dev · updated 2026-05-09
// what's in here
  1. The actual definition
  2. A real working decorator: timing
  3. Why functools.wraps matters
  4. Decorators with arguments
  5. Class-based decorators
  6. When NOT to use a decorator
  7. FAQ

TL;DR: Decorators are functions that wrap other functions to add behavior — logging, caching, timing — without touching the original code. They're not magic, they're function composition with @-syntax sugar.

The actual definition

A decorator is a callable that takes a function and returns a (usually) different function. Python's @decorator syntax is just sugar for func = decorator(func). Once you internalize that, every confusing decorator pattern becomes obvious.

A real working decorator: timing

Here's a decorator that measures how long any function takes. Drop it in a file, import it, slap @timed on something slow.

import functools
import time

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            elapsed_ms = (time.perf_counter() - start) * 1000
            print(f"{fn.__name__} took {elapsed_ms:.2f}ms")
    return wrapper

@timed
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

slow_add(1, 2)  # slow_add took 100.42ms

Why functools.wraps matters

Without @functools.wraps(fn) the wrapper steals the original function's name, docstring, and signature. Tools like pytest, Sphinx, and IDEs rely on those. Always include it.

// recommended — digitalocean DigitalOcean — $200 credit for new accounts, ideal for Python API hosting

Decorators with arguments

When you want @retry(tries=3), you need three layers: a function that takes config, returns a decorator, that returns a wrapper.

def retry(tries=3, delay=0.1):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(tries):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_exc = e
                    time.sleep(delay * (2 ** attempt))
            raise last_exc
        return wrapper
    return decorator

@retry(tries=3, delay=0.5)
def fetch_user(uid: int):
    # ... call API that occasionally times out
    return {"id": uid}

Class-based decorators

Sometimes you need state across calls. A class with __call__ works as a decorator and can hold instance state.

class CountCalls:
    def __init__(self, fn):
        functools.update_wrapper(self, fn)
        self.fn = fn
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.fn(*args, **kwargs)

@CountCalls
def greet(name):
    return f"hi {name}"

greet("a"); greet("b")
print(greet.count)  # 2

When NOT to use a decorator

If the wrapping behavior changes per-call, use a context manager instead. If it changes the function's signature, that's an antipattern — every call site has to read your decorator's source to understand it. Keep decorators boring and orthogonal.

// recommended — frontend-masters Frontend Masters — Python and FastAPI courses by working engineers

FAQ

Can I stack multiple decorators?

Yes. They apply bottom-up. @a\n@b\ndef f(): ... is equivalent to f = a(b(f)).

Are decorators slower than calling the function directly?

There's one extra function call per invocation. For hot paths in tight loops, that adds up. For everything else, it's free.

Should I use decorators for caching?

Use functools.cache or functools.lru_cache from the stdlib. They're battle-tested. Don't roll your own unless you have specific needs.

Related reading