Why does a Python decorator's wrapper need *args, **kwargs and a return?
answer
- The wrapper is what callers see
- It must accept any call shape
- Collect with stars, forward with stars
- No return means None
- Bookkeeping belongs in finally
basics
~20 sBecause the wrapper replaces the function it decorates, it is the callable everyone now invokes. Accepting *args and **kwargs lets it take any call shape, forwarding them calls the original correctly, and returning that call's value keeps the result from becoming None.
solid answer
~40 sAfter decoration the name points at the wrapper, so the wrapper's parameter list is the public signature and its return value is what callers receive. Declaring `def wrapper(*args, **kwargs)` makes it accept whatever the decorated function accepts — positional, keyword, or both — and passing them straight through as `func(*args, **kwargs)` reproduces the original call exactly. Forgetting the `return` in front of that call is the single most common decorator bug: the wrapped function still runs, but the wrapper falls off the end and every caller silently gets `None`. For anything that must happen even on failure, such as recording elapsed time or releasing a resource, put the inner call in a `try` and the bookkeeping in `finally`, so exceptions still propagate to the caller unchanged.
code
python · 11 linesdef logged(func):
def wrapper(*args, **kwargs):
print("start", func.__name__)
func(*args, **kwargs) # no return
return wrapper
@logged
def total(a, b):
return a + b
print(total(2, 3))go deeper
Memorise the shape: def wrapper(*args, **kwargs): return func(*args, **kwargs). Be able to say why the return is there and what callers get without it.
Explain the mechanics: collecting and re-expanding both argument kinds keeps the wrapper transparent, and a missing return substitutes None at every call site the decorator touches.
Demonstrate failure-path judgement — try/finally for bookkeeping, no blanket except, a monotonic clock, and logging rather than printing so the application owns the output.
Weigh what a widely applied wrapper costs: an extra frame in every traceback, a signature tools can no longer read, and one bug in the wrapper reaching every decorated call site at once.
### The wrapper is the function now The decorated name is bound to the wrapper, so from the caller's point of view the wrapper *is* the function. That single fact generates all three requirements. Whatever call shapes the original supported, the wrapper must support. Whatever the original returned, the wrapper must hand back. Whatever the original raised, the wrapper must let through. A wrapper is a stand-in, and the quality bar for a stand-in is that nobody outside notices. ### Why `*args, **kwargs` rather than a real signature A general-purpose decorator does not know what it will be applied to. Written as `def wrapper(a, b)`, it works on exactly one shape and breaks on the next function you decorate; written as `def wrapper(*args)`, it works until the first caller passes a keyword argument and gets `TypeError: wrapper() got an unexpected keyword argument`. Declaring both means the wrapper accepts any number of positional arguments and any keyword arguments, whatever they are named, and then *forwarding* them with the same two stars re-expands them into an ordinary call: ```python def wrapper(*args, **kwargs): return func(*args, **kwargs) ``` The pairing matters. Collecting with `*args, **kwargs` and forwarding with `*args, **kwargs` is transparent; collecting them and forwarding `args, kwargs` without the stars passes a tuple and a dict as two positional arguments and fails immediately. Note also what the wrapper does *not* do: it does not validate the arguments. If the caller's call is wrong for the underlying function, the `TypeError` is raised by the inner call, which is what you want — the error should describe the real function, not the wrapper. There is one honest cost. Because the wrapper's declared signature is `(*args, **kwargs)`, tools that read signatures see that instead of the real parameters, and errors from a genuinely bad call now come from one frame deeper. Restoring the introspectable signature is the job of the metadata-copying helpers, a separate concern from the forwarding itself. ### Why the `return` is not optional A Python function with no `return` returns `None`. If the wrapper calls the wrapped function but does not return its result, the wrapped function still executes — side effects happen, logs are written, the database row is inserted — and every caller receives `None`. This is uniquely nasty because it is silent: nothing raises at the decoration site, nothing raises at the call, and the failure surfaces wherever the result was eventually used, often as an `AttributeError` or a `TypeError` on `None` in an unrelated module. A decorator that is applied broadly can inject that bug into hundreds of call sites at once. The rule is mechanical: the inner call is either `return func(...)`, or its value is captured in a local and returned before the wrapper ends. The same discipline covers the value's *identity*: forward it unchanged. A wrapper that "helpfully" converts the result, or returns a truthiness check, changes the contract of every function it decorates. If the callee is an `async def` function, the inner call returns a coroutine object and the wrapper must hand that object back untouched; awaiting inside a synchronous wrapper is not possible, and wrapping asynchronous callees is its own shape. ### Exceptions and the `try`/`finally` shape A timing or logging wrapper that records its measurement *after* the inner call records nothing when the call raises — the exception unwinds straight past the bookkeeping line. Since the failing calls are usually the interesting ones, the correct shape is: ```python import time def timed(func): def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: elapsed = time.perf_counter() - start print(f"{func.__name__} {elapsed:.6f}s") return wrapper ``` `finally` runs on the success path and the exception path alike, and because there is no `except`, the exception continues to propagate with its traceback intact. Two anti-patterns are worth naming. Catching `Exception` in a wrapper and returning `None` converts every failure into the silent-`None` bug described above. Catching and re-raising a *new* exception discards the original type that callers were catching; if you must add context, raise from the original so the chain survives. ### Choosing the clock and the log channel Measure with `time.perf_counter`, the monotonic high-resolution clock, rather than `time.time`, which can move backwards when the system clock is adjusted and is not intended for intervals. For output, a decorator applied library-wide should emit through `logging` rather than `print`, so the application decides the destination and level; that also keeps the wrapper cheap when the level is disabled.
- What breaks if the wrapper forwards args and kwargs without the stars?The call becomes `func(args, kwargs)` — two positional arguments, a tuple and a dict. Any function expecting real parameters raises `TypeError` about argument counts, and a function that happens to accept two positionals silently receives garbage. Collection and forwarding must use the same star notation.
- Should a wrapper catch exceptions from the wrapped call?Only when swallowing them is the decorator's actual purpose, and even then it must be explicit about what it returns instead. For timing, logging or auditing, use `try`/`finally` with no `except`: the bookkeeping runs on both paths and the original exception propagates untouched, preserving its type and traceback for the caller's own handlers.
- Why prefer time.perf_counter over time.time in a timing wrapper?`time.perf_counter` is monotonic and has the highest resolution the platform offers, so it is defined for measuring intervals. `time.time` is wall-clock: it can jump forward or backward when the system clock is adjusted, which can produce negative or wildly wrong durations in a long-running process.
saying these in an interview costs you the question
- Writes wrapper(*args) and forgets keyword arguments
- Forwards args and kwargs without the stars
- Omits the return and reports None as a mystery
- Catches Exception and returns None from the wrapper
- Times after the call so failures record nothing
- Thinks the wrapper must restate the real signature