Decorators from Scratch: A Practical Deep Dive into Python's Most Misunderstood Feature

Learn to write Python decorators from first principles — closures, functools.wraps, decorators that take arguments, class-based stateful decorators, stacking, and the pitfalls that trip everyone up.

Decorators from Scratch: A Practical Deep Dive into Python's Most Misunderstood Feature

Decorators are one of those Python features that look like magic until they suddenly click. You have seen them everywhere — @property, @staticmethod, @app.route("/") in Flask, @pytest.fixture — but writing your own can feel intimidating. The good news is that a decorator is nothing more than a function that takes a function and returns a function. Once you internalize that sentence, the rest is detail.

This guide builds decorators from first principles: closures, the @ syntax, preserving metadata, decorators that accept arguments, class-based decorators that carry state, and how to stack them. Every snippet is runnable and idiomatic.

The one idea behind every decorator

In Python, functions are ordinary objects. You can pass them around, store them in variables, and return them from other functions. A decorator exploits exactly this. Here is the simplest possible one, written the long way:

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def add(a, b):
    return a + b

add = announce(add)   # wrap it by hand
print(add(2, 3))
# calling add
# 5

The @ syntax is pure sugar for that last reassignment. Writing @announce above a function definition does exactly the same thing as add = announce(add):

@announce
def add(a, b):
    return a + b

The inner wrapper uses *args, **kwargs so it can forward any call signature to the wrapped function untouched. This is the pattern you will repeat constantly, so make it muscle memory.

Always use functools.wraps

There is a subtle bug in the version above. Because add now points at wrapper, introspection breaks: add.__name__ reports "wrapper", the docstring vanishes, and tools that read signatures get confused. The fix is functools.wraps, a decorator you apply to your wrapper to copy the wrapped function's metadata across.

import functools

def announce(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@announce
def add(a, b):
    """Return the sum of two numbers."""
    return a + b

print(add.__name__)   # add   (not 'wrapper')
print(add.__doc__)    # Return the sum of two numbers.

Treat @functools.wraps(func) as non-negotiable boilerplate. Leaving it out is the single most common decorator mistake, and it silently degrades debuggers, documentation generators, and anything that relies on inspect.signature.

A genuinely useful example: timing

Let's write something you would actually reach for — a decorator that measures how long a function takes. Use time.perf_counter(), which is the correct clock for measuring elapsed wall-clock durations.

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:.6f}s")
        return result
    return wrapper

@timer
def crunch(n):
    return sum(range(n))

crunch(1_000_000)
# crunch took 0.012345s

Notice the shape: capture something before the call, run the real function, then do something with the result or the timing afterward. Logging, caching, access control, and input validation all follow this same before/after skeleton.

Decorators that take arguments

What if you want to configure the decorator — say, retry a flaky function a specific number of times? This requires one more layer of nesting. A decorator that takes arguments is really a function that returns a decorator. So you end up with three levels: the argument-taking factory, the actual decorator, and the wrapper.

import functools

def retry(times=3, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as exc:
                    last_error = exc
                    print(f"attempt {attempt} failed: {exc}")
            raise last_error
        return wrapper
    return decorator

@retry(times=3, exceptions=(ValueError,))
def parse_port(raw):
    return int(raw)   # raises ValueError on bad input

When Python sees @retry(times=3, ...), it first calls retry(...), which returns decorator, and then applies that returned decorator to parse_port. The three-layer structure is the price of configurability. If you find it hard to remember, read it inside-out: wrapper is the new function, decorator wraps a function, and retry hands you a decorator.

Making one decorator work with and without parentheses

A common polish is letting a decorator be used as either @log or @log(level="DEBUG"). The trick is to detect whether the first positional argument is the function being decorated:

import functools

def log(_func=None, *, level="INFO"):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] {func.__name__}")
            return func(*args, **kwargs)
        return wrapper
    # Called as @log  -> _func is the function
    # Called as @log() -> _func is None
    return decorator(_func) if _func else decorator

@log
def a(): ...

@log(level="DEBUG")
def b(): ...

The keyword-only * forces callers to name level explicitly, which keeps the dual-mode logic unambiguous.

Class-based decorators for stateful behavior

Sometimes a closure isn't the cleanest home for state. Any object with a __call__ method is callable, so a class can be a decorator. This shines when you want to expose that state as an attribute — for example, counting how many times a function was called.

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
        return self.func(*args, **kwargs)

@CountCalls
def ping():
    return "pong"

ping()
ping()
print(ping.count)   # 2

Here functools.update_wrapper is the function-call equivalent of the @functools.wraps decorator — it copies __name__, __doc__ and friends onto the instance. Because the count lives on the instance, you can read ping.count from the outside, which a plain closure can't offer as cleanly.

Stacking decorators

You can apply several decorators to one function. They compose from the bottom up: the decorator closest to the function runs first during application, and its wrapper sits innermost at call time.

@announce
@timer
def compute(x):
    return x * x

compute(5)
# calling compute        <- announce's wrapper (outermost)
# compute took 0.000001s <- timer's wrapper (innermost)

Read a decorator stack as a pipeline that wraps inward. If order matters — for instance, authentication before logging — arrange the lines accordingly and test the behavior, because swapping two decorators can change results in surprising ways.

Don't reinvent the standard library

Before writing a decorator, check whether functools already ships one. @functools.lru_cache and the simpler @functools.cache memoize return values keyed by arguments; @functools.cached_property computes a property once and stores it on the instance. These are battle-tested and thread-aware.

import functools

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))   # instant, thanks to memoization

Common pitfalls

A few traps catch people repeatedly. First, forgetting functools.wraps, which breaks introspection as shown earlier. Second, accidentally calling the function inside the decorator instead of returning the wrapper — remember the decorator body runs once at definition time, while the wrapper runs on every call. Third, mutable default arguments used as caches; prefer an explicit dictionary created inside the decorator. Finally, decorating methods: since a bound method receives self as its first positional argument, your *args forwarding already handles it, but be careful when a class-based decorator wraps an instance method, as it can interfere with descriptor binding.

Wrap-up and next steps

Every decorator, no matter how elaborate, reduces to the same core: a callable that takes a function and returns a replacement. Add functools.wraps to preserve identity, add a layer of nesting when you need arguments, and reach for a class when you need externally visible state. From here, explore functools.lru_cache for real caching, look at how web frameworks use decorators for routing, and try writing a small validation decorator that checks argument types against annotations. Once the pattern is second nature, you will start seeing clean places to factor cross-cutting concerns out of your functions — which is exactly what decorators are for.