Decorators Demystified
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This cover opens the engine-room post, promising to replace the feeling of magic with a concrete picture of the machinery. The headline 'How Decorators Work' signals a shift from what and why toward the actual mechanics: closures, argument forwarding, and the precise rewrite the @ performs.
The framing matters because the previous two posts treated the wrapper somewhat as a black box. This post opens the box. The goal is that by the end, a reader can write the standard decorator skeleton from memory and explain every line of it — why the inner function exists, why it takes *args, why functools.wraps is there, and how stacking resolves.
The first mechanics slide establishes the structural shape: a decorator is a higher-order function, meaning it takes a function and returns a function. The crucial subtlety is that you return the wrapper itself, not the result of calling it — return wrapper, never return wrapper(). The returned wrapper is the object the name gets rebound to.
This distinction trips up many beginners, who write return wrapper() and then wonder why their decorated function runs once at definition time and then breaks. Returning the function object means the wrapper is what executes each time the decorated name is called. Holding this straight — return the function, not its result — is the foundation everything else in the post rests on.
This slide presents the canonical decorator skeleton that the rest of the post and the entire next post build from. It has every essential piece: the outer function taking func, the @functools.wraps(func) line, the inner wrapper accepting *args and **kwargs, the call to func with those arguments forwarded, the before-and-after comment markers, and the return of the wrapper.
Memorizing this skeleton is genuinely worthwhile because nearly every decorator you'll ever write is a fill-in-the-blanks of it. The 'before' and 'after' comments mark where your custom behavior goes; everything else is boilerplate that makes the decorator general and well-behaved. Each remaining slide in this post zooms into one line of this skeleton and explains why it's there.
This slide explains the *args/**kwargs line in the skeleton. A general-purpose decorator can't know in advance how many arguments the functions it wraps will take — some take none, some take three, some take keyword arguments. Declaring wrapper(*args, **kwargs) lets the wrapper accept any call shape, and forwarding func(*args, **kwargs) passes everything through unchanged.
Without this, a decorator would only work on functions matching one fixed signature, which defeats the purpose of a reusable wrapper. This is also the mechanism that quietly makes decorators work on methods: self is just the first positional argument, so it flows through *args automatically. That method-compatibility point gets its own treatment in the mistakes post, but it originates here.
Here we explain the closure, the concept that makes the whole thing work. After decorator(func) returns, the wrapper still needs access to func in order to call it. It retains that access because wrapper is a closure: it closes over the func variable from the enclosing decorator scope, keeping it alive even after decorator has returned.
The consequence worth internalizing is that each decorated function gets its own wrapper instance holding its own captured func. When you decorate ten functions with the same decorator, you create ten closures, each remembering its specific original. This is also where per-function state like a memoization cache lives — in the enclosing scope, captured by the closure — which connects directly to the shared-state discussion in the mistakes post.
The stack diagram visualizes the closure relationship as nested scopes. The outermost level is decorator(func), where the func parameter lives. Inside it, def wrapper closes over that func. When wrapper later calls func(*args), it reaches up into the enclosing scope to find the captured function. The return wrapper at the bottom carries the whole closure — including its grip on func — out into the world.
Rendering this as a stack reinforces that the captured variable isn't copied or passed at call time; it's referenced from the enclosing scope through the closure. Understanding scopes as nested in this way is what makes closures click, and closures are the single most important prerequisite for reading any decorator more complex than the trivial example.
This code slide ties the abstract pieces together by tracing a real call through a timing decorator. The wrapper records a start time, calls func(*args, **kwargs) to do the real work, prints the elapsed time using func.__name__, and returns the original result. Calling work(1000) runs the wrapper, which runs work, which sums the range.
Walking through this with a REPL is the fastest way to see the machinery in motion. Note how func.__name__ correctly prints 'work' rather than 'wrapper' — that's functools.wraps doing its job, which the very next slide explains. The example is deliberately the same timed decorator that opens the code-heavy post, so it does double duty as a bridge between understanding the mechanics and writing real decorators.
This slide explains the one line beginners most often omit: @functools.wraps(func). Because the wrapper replaces the original function, by default func.__name__ becomes 'wrapper' and the docstring is lost. functools.wraps copies the original's __name__, __doc__, __module__, and other metadata onto the wrapper, so introspection still reports the truth.
The reason this matters in practice is that tooling relies on that metadata. Debuggers, stack traces, help(), Sphinx documentation, and frameworks that inspect function names all see the wrapper without wraps. The bug is insidious because the code runs fine — it only surfaces when something introspects the function, often far from where the decorator was written. This is important enough that it returns as the first entry in the mistakes post.
This code slide explains stacking, where multiple decorators apply to one function. @a above @b above def f desugars to f = a(b(f)). The slide spells this out so the order is unambiguous: b wraps f first, then a wraps the result. The annotation also previews the runtime behavior — at call time, a's code runs outermost and b's runs inside it.
The desugaring trick from the concept post pays off again here. Whenever stacked decorators confuse you, rewrite them as nested calls from the bottom up and the relationship becomes mechanical. The split between application order and execution order is the genuinely subtle part, which is why the diagram and the closing mistake both reinforce it.
The cycle diagram makes the stacking order concrete by walking the call through and back out. a wraps last but runs first, so its before-code executes at the outermost layer. b wraps first but runs inside a. f, the real function, runs innermost. Then control unwinds: b's after-code returns up into a, and a's after-code returns last.
Visualizing it as a cycle — in through the outer layers, hit the core, back out through them — captures the symmetry of wrapping. Each decorator gets a turn on the way in and a turn on the way out, like nested function calls (which is exactly what they are). This is the standard onion model of middleware, and recognizing it here helps when you later meet the same pattern in web frameworks and request pipelines.
The closing mistake isolates the single most confusing aspect of stacking: people conflate application order with execution order. Decorators are applied bottom-up — the one nearest the def wraps first — but they execute top-down, with the topmost decorator's before-code running first. Reversing these in your head leads to bafflement about why an outer decorator sees the inner one's result.
The fix is the same desugaring discipline used throughout the series: read @a @b def f as f = a(b(f)). From that single rewrite, both orders fall out unambiguously — a is the outer call so it runs first and last, b is inner so it runs in between. When stacked decorators surprise you, don't guess; write the nested calls and trace them.
This CTA closes the mechanics post and turns toward application. With the machinery understood — closures, argument forwarding, wraps, and stacking — the reader is equipped to write real decorators rather than copy them.
The teaser lists exactly what the code-heavy post delivers: a timer, a cache, a retry, and the trickier decorator-with-arguments. It promises practical, paste-ready code, signaling a shift from theory to a hands-on, REPL-open session.