datarekha
Patterns June 2, 2026

Decorators are just functions that wrap functions

The @ symbol is pure syntactic sugar for a pattern you already know: pass a function in, get a smarter function back.

8 min read · by datarekha · pythondecoratorshigher-order-functionsfunctionsclosures

Most Python developers learn decorators the wrong way: they see @login_required above a view, copy the pattern, and move on. The magic holds together until they need to write one, and then everything falls apart.

The confusion is entirely caused by the syntax. Strip the @ away and what remains is a concept you have used since your first week of programming: a function that calls another function.

The idea fits in one sentence

A decorator is a callable that takes a function as its argument and returns a new callable. That new callable usually calls the original, but it can run code before it, after it, modify inputs, catch exceptions, or decide not to call the original at all. The original function never changes. The caller never knows.

That is it. Everything else — stacking, parameterized decorators, class-based decorators — is elaboration on that one sentence.

What you were already doing

Before the @ syntax existed, programmers wrapped functions by hand. Suppose you want to log every call to a slow database query:

def fetch_user(user_id):
    # imagine an expensive DB call here
    return {"id": user_id, "name": "Ada"}

def logged(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"done")
        return result
    return wrapper

fetch_user = logged(fetch_user)   # manual wrapping

You pass fetch_user into logged. You get back wrapper. You reassign the name. From this point on, fetch_user is actually wrapper, and wrapper calls the original behind the scenes.

The @ syntax does exactly this reassignment, automatically, at definition time:

@logged
def fetch_user(user_id):
    return {"id": user_id, "name": "Ada"}

Python reads this as fetch_user = logged(fetch_user). Not metaphorically. Literally. The desugaring is in the language spec.

Why wrappers need functools.wraps

Here is the problem with the naive wrapper above: the function you get back has the wrong identity.

print(fetch_user.__name__)  # prints "wrapper", not "fetch_user"
print(fetch_user.__doc__)   # None — the original docstring is gone

Debugging tools, test frameworks, and introspection libraries depend on __name__ and __doc__. When you wrap a function without preserving them, stack traces lie and auto-generated docs go blank.

functools.wraps fixes this with one decorator applied to wrapper itself:

import functools

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

Now fetch_user.__name__ correctly returns "fetch_user". The wrapper is transparent. Always use functools.wraps. There is no argument for skipping it.

DECORATOR WRAPPERbefore logiclog, validate, start timer, check auth …original functionunchanged, unawareafter logiccallresultcallerenhanced
The caller interacts only with the wrapper. The original function sits inside, untouched.

A real decorator worth keeping

Timing a function in production is a classic use case. Here is a @timed decorator you can drop into any codebase:

import functools
import time

def timed(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__} finished in {elapsed * 1000:.1f} ms")
        return result
    return wrapper

@timed
def compute_recommendations(user_id):
    # pretend this calls a model
    time.sleep(0.12)
    return ["item_a", "item_b"]

compute_recommendations(42)
# compute_recommendations finished in 120.4 ms

Notice what did not happen: compute_recommendations did not change. Its tests do not change. Its type signature is preserved (thanks to *args, **kwargs). The timing logic lives in exactly one place and works on any function you attach it to.

That reusability is the real payoff. Every decorator you write is a reusable cross-cutting concern — behavior that belongs to many functions but should not live inside any single one of them.

The closure is where the magic actually lives

When timed runs and returns wrapper, the func variable — the original function you passed in — is captured inside wrapper’s closure (a local variable environment that survives beyond the outer function’s return). Every future call to wrapper has access to that captured func.

This is why decorators work without globals. wrapper does not look up func by name at call time. It has a private reference baked in at decoration time. That also means each decorated function gets its own wrapper with its own captured func — decorating two different functions produces two independent wrappers.

Understanding closures dissolves about half the remaining confusion around decorators. The other half disappears once you write one.

Stacking decorators reads inside-out

Python applies stacked decorators bottom-up, closest to the function first:

@logged
@timed
def fetch_user(user_id):
    ...

This expands to fetch_user = logged(timed(fetch_user)). The @timed wrapper is on the inside; @logged wraps that result. When you call fetch_user, the outermost wrapper (logged) runs first, then timed, then the original function on the way down — and the result unwinds back out in reverse order.

Draw it as nested boxes if you need to reason about it. The outermost decorator controls the entry and exit of the whole stack. This ordering matters when decorators are not commutative — a @retry wrapping a @timed will time each individual attempt, whereas @timed wrapping @retry times the total including retries.

When decorators are the wrong tool

Decorators excel at uniform, stateless cross-cutting behavior: logging, timing, caching, rate-limiting, auth checks. They struggle when:

The logic depends on the function’s return type in a complex way. You need to inspect or transform the function’s arguments non-generically. The behavior only applies to three specific functions and is unlikely to generalize.

In those cases, just call a helper function directly. A decorator signals to readers that the behavior is reusable and generic. Overusing the pattern produces code that looks clean but requires reading three layers of indirection to understand a single function call.

The mental model to carry forward

Every time you see @something above a def, substitute it mentally with:

the_function = something(the_function)

That substitution is complete and exact. From that starting point, parameterized decorators (functions that return decorators), class-based decorators (objects with a __call__ method), and decorator factories all become straightforward variations on one theme.

The @ syntax is not magic. It is an alias for an assignment. Python just made it beautiful.

Skip to content