Decorators Demystified
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This cover frames the final post as a field guide to the ways decorators go subtly wrong. The key framing line — that most decorator bugs aren't about syntax but about identity and timing — sets the theme. The errors here don't produce neat SyntaxErrors; they produce functions that lie about their names, share state unexpectedly, or hide failures.
Closing the day with mistakes is intentional. After learning what decorators are, why they matter, how they work, and how to write them, the reader is ready to learn what breaks. Each trap is paired with a concrete fix, so the post hardens real skill rather than just cataloging hazards.
The first trap is the one already foreshadowed twice: forgetting @functools.wraps. Without it, the wrapper silently overwrites the original's identity — __name__ becomes 'wrapper', the docstring vanishes, and any tool that introspects the function reports the wrapper instead of the real thing. The insidious part is that the code runs perfectly; the bug only surfaces when something inspects the function.
This matters because so much tooling depends on accurate metadata: stack traces, debuggers, help(), documentation generators, and frameworks that dispatch on function names. A missing wraps can break a framework in ways that seem unrelated to your decorator, sending you on a long debugging detour. The fix is a single line, which is why it should be muscle memory on every wrapper you write.
This code slide demonstrates the missing-wraps bug concretely and shows the one-line fix. The 'bad' decorator omits @functools.wraps, so greet.__name__ prints 'wrapper' and greet.__doc__ prints None — the original 'say hi' docstring is gone. The comment points to the fix: add @functools.wraps(func) directly above the wrapper definition.
Seeing the actual wrong output is more convincing than being told it happens. Running this snippet and then adding the wraps line to watch the metadata come back is a two-minute exercise that cements the habit permanently. The takeaway is that wraps isn't optional polish; it's what keeps your decorated function honest about its own identity to every tool that asks.
The second trap is the parentheses mix-up: confusing @retry with @retry(). A plain decorator is applied as @retry. A decorator factory that takes arguments is applied as @retry() or @retry(3). Swap them and you get a cryptic error — either Python tries to call something that isn't a decorator, or it applies a decorator to None.
The root cause is the layer count covered in the previous post. @deco passes the function straight to deco. @deco(args) calls deco(args) first and expects a decorator back. The two are structurally different, and the parentheses are the visible signal of which structure you're dealing with. This trap is the runtime counterpart to the 'forgetting the extra layer' mistake from the code post, viewed from the call site rather than the definition.
This code slide shows the parentheses trap in action with a repeat decorator that takes a count. repeat is correctly written as a three-layer factory, but it's applied as @repeat instead of @repeat(3). Python calls repeat(hi), binding hi to the n parameter, and then deco never receives its func argument — yielding a TypeError about a missing argument.
The error message points at deco, not at the @ line, which is exactly why this trap confuses people: the symptom appears one layer away from the cause. The discipline that prevents it is mechanical — match the @ usage to the decorator's structure. If the decorator takes arguments, you must call it with parentheses; if it doesn't, you must not. When in doubt, desugar the @ line and check what each call returns.
The third trap concerns state that's shared without the author realizing it. A cache, counter, or accumulator defined in the decorator's enclosing scope is shared across every call to that one wrapped function. For memoization that's exactly the goal, but if you expected fresh state per call, it's a surprise — and a plain dict cache never evicts, so on a long-running process it can grow without bound and leak memory.
The nuance is that shared state isn't a bug per se; it's a property you must choose deliberately. The mechanics post established that closure-scope variables persist and are shared; this trap is what happens when you forget that. The two failure modes — unexpected sharing and unbounded growth — both trace back to not being intentional about where state lives and whether it's ever cleaned up.
The comparison diagram pins down the two places state can live and what each implies. State in the closure scope — the decorator's enclosing function — is shared across all calls, persists for the life of the process, suits caches and counters, and risks leaking if unbounded. State inside the wrapper body is fresh on every call, discarded after the function returns, suits per-call temporaries, and carries no leak risk.
Making this an explicit choice is the lesson. Before defining a variable in a decorator, ask which column you want: do you need it to accumulate across calls, or to reset each time? The answer dictates where you put it. Most accidental-state bugs come from defaulting to the closure scope without asking the question, then being surprised when values persist between calls.
The fourth trap is decorating methods rather than plain functions. Methods receive self as their implicit first argument, and the good news is that a properly written wrapper handles this automatically: because it uses (*args, **kwargs) and forwards them, self simply rides along as args[0]. The trap is hardcoding a fixed signature like wrapper(arg) — then self occupies the slot meant for the first real argument and everything misaligns.
This is why the mechanics post insisted on *args/**kwargs as the default. It's not just about supporting variadic functions; it's what makes one decorator work uniformly on functions, methods, classmethods, and staticmethods alike. The rule is to never assume a signature in a general decorator. If you find yourself naming specific parameters in the wrapper, you've coupled the decorator to one shape and broken its reusability.
This code slide proves the method case works when the wrapper is written generically. log_call uses (*args, **kwargs) and forwards them, so when it decorates Bank.deposit, the self instance and the amount argument both flow through untouched. No special handling is needed; the generic skeleton already does the right thing.
The reassuring takeaway is that you don't need a separate decorator for methods — the standard skeleton already handles them. The only requirement is the discipline of using *args/**kwargs, which the series has stressed from the start. Seeing self pass through correctly closes the loop on why that pattern was non-negotiable: it's what makes a single decorator universal across every callable shape Python has.
The fifth trap is the error-handling one the retry examples set up: silently swallowing exceptions. A decorator that catches Exception and returns None on failure makes the call site believe it succeeded while actually handing back nothing. The real failure is hidden, and the bug surfaces somewhere downstream as a baffling None where data was expected.
The fix has two acceptable forms. Either re-raise after you've genuinely handled what you can — for instance, after exhausting all retries — so the caller learns the operation failed; or narrow the except clause to the specific exception you actually intend to handle, letting everything else propagate. The anti-pattern is the blanket except Exception that stays quiet. A decorator should never turn a loud failure into a silent wrong answer.
The tips slide consolidates all five traps into a single pre-flight checklist for any decorator. Add @functools.wraps to every wrapper. Match @deco versus @deco() to the decorator's layer count. Decide consciously whether your state is shared or per-call. Use *args/**kwargs so methods work. Re-raise or narrow exceptions instead of swallowing them. And prefer standard-library decorators when they fit.
Running this list against a decorator before considering it done catches the overwhelming majority of real-world decorator bugs. Each item corresponds directly to one trap dissected in the post, so the checklist doubles as a summary of the whole day. The final bullet ties back to the code post's lru_cache lesson: the safest decorator is often the one you didn't have to write.
This CTA closes Day 19 and the decorators arc. It acknowledges the day is wrapped and points forward to the next Python concept in the series, maintaining the momentum of the daily cadence.
The teaser names generators and lazy evaluation as the next topic, a natural follow-on: like decorators, generators are a Python feature that looks like syntax magic until you understand the mechanism underneath. The framing invites the reader who just demystified decorators to come back and do the same for the next building block of fluent Python.