The first decorator I read in production code took me about an hour to understand. It was a retry decorator with arguments, double-nested, and decorated a method on a class. I traced through three layers of def wrapper(...) definitions before I could explain to myself what the decorated function actually did when you called it. By the time I figured it out, I had also figured out that decorators are not a special language feature with mystical rules. They are a function that takes a function and returns a function. That is the whole story.
The argument I want to make in this piece is that almost every decorator you will ever write or read in a real Python codebase falls into one of about five jobs, and once you have written a small, ugly version of each one yourself, the production versions are easy to read. I am going to show one example per job, in the order that built my own understanding.
The pattern, in one sentence
A decorator is a callable that takes the decorated function as its only argument and returns a replacement callable. Python sugar lets you write:
The sugar is exactly equivalent to:
Every decorator gotcha you will ever hit in real code traces back to that single rebinding line. The original target is replaced; references to the name now go through the wrapper. The original function is still in there, but it is reachable only through the wrapper's closure (or through __wrapped__ if you used functools.wraps).
Example 1: a timing decorator (the simplest useful one)
This is the first decorator I write when I am benchmarking new code. No arguments, no class, no async. The point is to show the wrapper-returning pattern as cleanly as possible.
Three details that matter even at this size. First, *args, **kwargs so the wrapper is transparent to any signature. Second, functools.wraps(fn) copies the original function's name, docstring, and attributes onto the wrapper, so introspection (help(slow_query)) does not show "wrapper". Third, the wrapper returns result; forgetting that line silently turns every decorated function into one that returns None.
If you understand timed, you understand the shape of every decorator in this article. The other four are variations on the same skeleton.
Example 2: caching with functools.lru_cache (and why I rarely write my own)
The standard library already has the cache decorator most people need.
lru_cache keys the cache on the arguments, so the decorator only works with hashable arguments. It also evicts entries on a least-recently-used basis once the cache fills, which is what you want most of the time.
A hand-rolled cache decorator is a useful exercise once, to internalise the pattern. After that, reach for functools.lru_cache (or functools.cache if you want unbounded). The hand-rolled version exists in the wild because someone needed a cache key that was not the function arguments (a hash of one specific field, say) or wanted invalidation hooks. If neither applies, the standard library wins.
The gotcha I have hit twice: lru_cache on a method holds a reference to self, which keeps the instance alive as long as the cache entry lives. On long-running services, this is a memory leak. Use it on free functions or on classmethods; on instance methods, keep maxsize small or use a different cache.
Example 3: a decorator that takes arguments (the one that confuses everyone)
The moment you want @retry(times=3) instead of just @retry, you need three layers instead of two. Reading this code is the moment most engineers stop trusting decorators; I want to show that the third layer is just "a function that returns a decorator".
Reading the call site top-down: retry(times=3, delay=0.5) is called immediately, returning decorator. Python applies decorator to flaky_call. decorator(flaky_call) returns wrapper. The name flaky_call is rebound to wrapper. Three function calls happen at decoration time, before the first invocation.
If you trace through this once on paper, the structure becomes recognisable in any decorator-with-arguments you read after. The three layers are not arbitrary; they correspond to three different binding times: configuration arguments, the target function, and the call arguments. Each layer captures the previous one in its closure.
Example 4: an auth decorator (the framework pattern)
Flask, Django, FastAPI, and most other web frameworks lean on this shape. The decorator inspects the request, decides whether to proceed, and short-circuits with an error if not.
The pattern matters because it scales: most cross-cutting concerns (auth, logging, request validation, response formatting) end up as decorators in this shape. The framework provides the request object as the first argument by convention; the decorator pulls what it needs and decides whether to proceed.
The gotcha here is order. If you put @logged on top of @require_role("admin"), every call gets logged, including the rejected ones, because logged becomes the outermost wrapper and runs before require_role ever sees the request. If you want only authorised calls in your logs, put @logged underneath, so the auth check short-circuits before logging happens. Decorator order is bottom-up at definition time, top-down at call time.
Example 5: a rate-limit decorator (the one with state)
Decorators can hold state in the closure. A simple rate limiter looks like this:
The history deque is shared across all calls to the decorated function, because it lives in the closure of the decorator. It does NOT live in the closure of the wrapper, which is created once per decoration, not per call. This single fact is what lets decorators implement caches, counters, locks, anything stateful.
The risk: the state is global to the decoration site. If two functions share a decorator and you want them to have separate buckets, the state has to be created inside decorator, not inside rate_limit. Easy mistake on day one.
The other spelling: class-based decorators
Everything above used nested functions, but a decorator is just "a callable that returns a callable", and a class with __call__ qualifies. The same timed decorator written as a class looks like this:
The class version makes state explicit. self.calls and self.total are attributes I can read from outside the wrapper, which is a real win when the state is something a caller actually wants to inspect (call counts, hit rates, last error). With the closure version, you have to expose the state through a separate function or attach it to the wrapper by hand.
My rule: I reach for a class-based decorator when the decorator owns enough state that I want to read it from the outside, or when the decorator needs configuration via subclassing. For the other ninety percent of cases, the closure version is shorter and reads better. The two are not mutually exclusive; pick the one that makes the state model obvious.
What about async functions?
The wrapper signature def wrapper(*args, **kwargs) does not work for async def targets. If fn is a coroutine function, calling it returns a coroutine that you have to await; a sync wrapper that just calls fn(*args, **kwargs) returns the coroutine object instead of the result. Most decorators I write today need to handle both shapes, and the cleanest way is to dispatch on asyncio.iscoroutinefunction:
This pattern shows up in real codebases (FastAPI's dependency system, the aiocache library, the various tenacity-style retry packages) and the dispatch is always at decoration time, not call time. Once you know the shape, you can read any "works on sync and async" decorator and predict its structure before you reach line ten.
When I stop reaching for a decorator
The pattern is so flexible that I have seen people decorate their way into write-only code. My rule for whether to write a decorator is: does the cross-cutting concern apply to multiple functions in roughly the same shape, with no per-function customisation needed? If yes, decorator. If no (or only for one function), inline it.
Specific anti-patterns I have walked back at code review.
- Decorators with seven optional kwargs. If the configuration is that complex, the decorator is doing too much. Split it into two.
- Decorators that depend on external state. A decorator that reaches into a global config object at call time is harder to test than the same logic inlined at the call site.
- Stacking five decorators on a single function. Each layer adds a frame to the traceback and a layer to the mental model. I cap at three for production code.
- Custom decorators where
functools.lru_cacheorfunctools.cached_propertywould do. Standard library wins on observability and battle-testing.
The one habit that makes them readable
If I had to give one rule to make decorators read well in a codebase, it would be: every decorator you write has a docstring that says what state it captures, what arguments it adds or removes from the wrapped function, and what exceptions it can raise. Three sentences, at the top of the decorator. Future-you reading the call site will be able to skim the docstring and trust it, instead of reading the source. Without that, a stack of three decorators is three layers of mystery; with it, it is three layers of contract.
Decorators are a tool I use almost daily and recommend without reservation, but only because the codebases I have most enjoyed working in treat them with the same care as any other public API. They are not a way to be clever. They are a way to take a cross-cutting concern, name it once, and stop writing the same eight-line wrapper around twenty functions.
