When does a decorator actually run, and what goes wrong when you stack two of them?
Decoration runs once, at definition time, so it happens on import rather than on call. Stacked decorators apply bottom-up and execute outermost-first, and each wrapper replaces the function's identity unless functools.wraps restores it, which is what breaks introspection and framework registration.
What the interviewer is scoring
- Does the candidate separate decoration time from call time without prompting
- Whether the bottom-up application order and the outside-in call order are both stated correctly
- That functools.wraps is explained by what depends on the copied attributes rather than as boilerplate
- Whether lru_cache on a method is recognised as retaining every instance it is called on
- Does the candidate identify import-time work inside a decorator as a testability problem
Answer
Decoration is a statement that runs, not an annotation
@retry above a function is not metadata. It is shorthand for calling retry(f) immediately after the def finishes and rebinding the name to whatever comes back. That happens exactly once, when the enclosing module is executed, which for almost all code means at import.
Two consequences follow, and most decorator surprises are one of them. Anything the decorator body does outside its inner wrapper happens at import: reading an environment variable, connecting to something, registering in a global table, computing a timestamp. And whatever the decorator returns is what the module-level name now refers to, so if the decorator forgets to return anything, the name becomes None and every call fails with an object-not-callable error that points at the call site rather than at the decorator.
The distinction between the decorator body and the wrapper body is therefore the one to be precise about. Code in the body runs once per decorated function; code in the wrapper runs once per call.
Stacking: applied upwards, executed downwards
With two decorators, the one nearest the def is applied first and the one furthest away wraps the result. The pair below is exactly route("/x")(cache(handler)).
@route("/x") # applied second, so it wraps the cached function
@cache # applied first, closest to the def
def handler(request): ...
Application order is bottom-up, and call order is the reverse: the outermost wrapper's code runs first on the way in and last on the way out. That reversal is why order is a correctness question rather than a stylistic one. Caching outside authorisation serves one user's cached result to another; authorisation outside caching checks permissions on every call and caches only the work. Retrying outside a timeout retries the whole timed operation; a timeout outside a retry bounds all the attempts together. In each pair both stacks compile and run, and one of them is a bug.
The other order that bites is a decorator that registers the function somewhere. If a framework's registration decorator is applied before your instrumentation decorator, the framework holds a reference to the undecorated function and calls it directly, so your wrapper never runs even though it is clearly in the source. Registration decorators belong at the top of the stack, applied last, so that what gets registered is the fully wrapped callable.
What the wrapper destroys
A wrapper is a different function object, and by default it advertises itself rather than the function it stands in for. __name__ becomes wrapper, the docstring is the wrapper's, __module__ and __qualname__ are wrong, and inspect.signature reports *args, **kwargs.
That matters because a surprising amount of machinery reads those attributes. Log lines and error messages built from __name__ all become wrapper. Documentation tooling shows the wrapper's empty docstring. Frameworks that dispatch on the signature — dependency injection in a web framework, fixture resolution in a test runner, argument parsing built from type hints — see two anonymous catch-alls and cannot resolve anything. And pickling a decorated function fails, because pickle looks the name up in the module and finds something else.
functools.wraps fixes it by copying the identifying attributes onto the wrapper and, importantly, setting __wrapped__ to point at the original. inspect.signature follows that link by default, which is how a decorated function still reports the parameters callers actually pass. Using it is not politeness, it is what keeps the decorated function substitutable for the undecorated one.
The cache that never lets an object go
The single most expensive decorator mistake in a long-running service is lru_cache on a method. The cache key includes every argument, and for a method the first argument is self, so the cache holds a strong reference to every instance it has been called on. Those instances can never be collected while the cache lives, and since the cache is attached to the function object on the class, it lives as long as the class does.
class Report:
def __init__(self, rows):
self.rows = rows # potentially large
@functools.lru_cache(maxsize=None) # keys include self: leaks every Report
def total(self):
return sum(self.rows)
An unbounded cache makes it a guaranteed leak; a bounded one makes it a bounded leak of maxsize whole objects, which for objects holding data is still substantial. The fix is to cache per instance rather than per class — functools.cached_property, available since Python 3.8, stores the computed value in the instance's own dictionary and dies with it — or to cache a module-level function of the plain values rather than of the object.
The same reasoning applies to any decorator that closes over accumulated state. A counter, a registry or a memo defined in the decorator body is shared by every call to that one decorated function for the life of the process, and in a threaded server that is shared across requests too.
Import-time work is the hidden cost
Because the decorator body executes on import, work placed there is work your test suite, your CLI and your health check all pay for before anything is called. A decorator that reads configuration at import time freezes whatever the environment was at that instant, so a test cannot change it afterwards and the only way to influence it is to control import order — which is why this shows up as an unexplained dependency on which test file ran first.
The rule that avoids all of it is to keep the decorator body cheap and pure, and to defer every decision to the wrapper. Read configuration on call, resolve dependencies on call, and let the decorator body do nothing but build and return the wrapper.
The decorator body runs once at import and the wrapper runs per call, so put nothing in the body you would not want to happen on import. Apply
functools.wrapsso the wrapper keeps the original's identity, and never key anlru_cacheonself.
Likely follow-ups
- Why does a decorator that reads configuration at import time make the code hard to test?
- What does inspect.signature do differently once __wrapped__ has been set?
- How would you write a decorator that works on both a plain function and an async one?
- Where would you put a per-instance cache instead of lru_cache on a method?
Related questions
- You cached something in a module-level dictionary. It works on your laptop and behaves oddly in production. What is different?mediumAlso on caching5 min
- How do you handle cache invalidation, and what goes wrong at scale?hardAlso on caching5 min
- You are about to change the address a hostname points to. What does the TTL actually promise you, and why do clients keep reaching the old address anyway?mediumAlso on caching4 min
- What goes into a cache key, and what happens when two requests that should get different responses collide on one?hardAlso on caching6 min
- Which status codes and method semantics do you insist on in a code review, how do the caching headers fit together, and is the API you just described REST?mediumAlso on caching5 min
- Design the asset delivery and deploy strategy for a large single-page app. What happens to a user who has the tab open when you ship?hardAlso on caching6 min
- A document was updated an hour ago and the assistant is still quoting the old version. Walk me through the diagnosis.hardAlso on caching6 min
- You have 50 ms for a model call in a request path. How do you make that budget?hardAlso on caching5 min