Ch. 9 · Python

Python Decorators and Function Metadata

A decorator replaces your function, so it owes callers the same return value, exceptions and metadata. functools.wraps and async cases.

~5 min readintermediateupdated Oct 7, 2026

A decorator is a function that takes a function and returns something to use in its place. @timed above def total(...) is shorthand for total = timed(total). After that line, every caller of total is calling whatever timed returned. That’s the contract to keep in mind: the replacement should behave like the original from the caller’s side (same arguments, same return value, same exceptions) and add its extra behaviour quietly. Most decorator bugs break one of those promises, and most decorator interview questions check whether you know them.

A timing decorator that breaks three things

Here’s a first attempt at a decorator that logs how long a function takes:

import inspect, time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
    return wrapper

@timed
def total(prices: list[float], tax: float = 0.2) -> float:
    """Return the gross total."""
    return sum(prices) * (1 + tax)

print(total([10, 20]))
print(total.__name__, total.__doc__, inspect.signature(total))
python
total took 0.000s
None
wrapper None (*args, **kwargs)
Text

Three things went wrong.

  1. The return value is gone. total([10, 20]) should be 36.0, and it printed None. The wrapper called func but never returned its result. This is the most common decorator bug, and in a code review it’s easy to miss because the logging still works.
  2. The metadata is the wrapper’s. total.__name__ is now "wrapper", the docstring is None, and the signature is (*args, **kwargs). Stack traces, help(), Sphinx docs, logging that uses func.__name__, and frameworks that inspect signatures (FastAPI, Click, pytest fixtures) all see the wrong thing.
  3. Timing on failure is lost. If func raises, the print never runs. That one’s minor here, but for a decorator that releases a lock or closes a span in a tracing system it isn’t.

Keeping the contract with functools.wraps

The corrected version returns the result, uses try/finally so the timing runs either way, and applies functools.wraps:

import functools, time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
    return wrapper
python

With the same total function:

total took 0.000s
36.0
total Return the gross total. (prices: list[float], tax: float = 0.2) -> float
36.0
Text

functools.wraps(func) copies __module__, __name__, __qualname__, __doc__, __annotations__ and a few more onto the wrapper, merges __dict__, and sets wrapper.__wrapped__ = func. That last attribute is why inspect.signature shows the real parameters: it follows __wrapped__ back to the original. It also gives you an escape hatch in tests: total.__wrapped__([10, 20]) calls the undecorated function and printed the final 36.0 above without the timing line.

Notice what the wrapper doesn’t do: it doesn’t catch exceptions. A decorator that wraps the call in try/except Exception: log(...) and carries on has changed the function’s contract, because callers that relied on an error now get None. If a decorator is meant to handle errors (retry, fallback), that should be its name and its whole purpose.

Decorators that take arguments

@retry(times=3) adds a layer. retry(times=3) is evaluated first and must return the actual decorator, which then receives the function. So there are three nested functions:

def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    print(f"attempt {attempt} failed: {e}")
                    if attempt == times:
                        raise
        return wrapper
    return decorator

calls = {"n": 0}

@retry(times=3)
def fetch():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("reset by peer")
    return "200 OK"

print(fetch())
python
attempt 1 failed: reset by peer
attempt 2 failed: reset by peer
200 OK
Text

The retry catches only ConnectionError, so a ValueError from a bug still fails immediately, and the last failure is re-raised with a bare raise so the caller sees the original traceback.

Forgetting the parentheses is a classic mistake:

@retry
def oops():
    return 1

oops()
python
TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func'
Text

@retry passed the function as times, so oops is now bound to the inner decorator, and calling it with no arguments fails. The error appears at the call site, not where the decorator was misused, which makes it confusing in a large codebase. Some libraries support both @retry and @retry(times=3) by checking whether the first argument is callable. That’s convenient but harder to read, so pick one form unless you’re writing a public library.

Async functions need an async wrapper

Apply the sync timed decorator to a coroutine function and it silently measures the wrong thing:

@timed
async def load():
    await asyncio.sleep(0.1)
    return "data"

print(asyncio.run(load()))
python
load took 0.000s
data
Text

Calling load() only creates a coroutine object; the work happens later when it’s awaited. The wrapper timed the creation and returned the coroutine, which asyncio.run then awaited outside the timing. Worse, inspect.iscoroutinefunction(load) is now False, and frameworks that check it (to decide whether to await) may treat the function as sync. functools.wraps doesn’t help here: it copies attributes, but whether a function is a coroutine function depends on how the wrapper itself was defined, and a plain def wrapper is not one.

The fix is to detect coroutine functions and return an async def wrapper for them:

def timed(func):
    if inspect.iscoroutinefunction(func):
        @functools.wraps(func)
        async def awrapper(*args, **kwargs):
            start = time.perf_counter()
            try:
                return await func(*args, **kwargs)
            finally:
                print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
        return awrapper
    ...  # the sync wrapper from before
python
load took 0.102s
data
Text

Generators have the same problem: a sync wrapper around a generator function times only the creation of the generator object, not the iteration.

Order and import-time behaviour

Stacked decorators apply bottom-up. @a over @b over def f means f = a(b(f)), so b wraps first and a wraps the result. With @app.route and @login_required, putting them in the wrong order can register the unprotected function with the router.

Decorators also run at import time, once per decorated function. A decorator that registers handlers in a global dict, reads config, or opens a connection does that work during import. That’s how route registration and plugin systems work, but it’s also a source of import side effects that slow startup and complicate tests.

Interview exercise: a cache decorator review

A teammate writes def cache(func): store = {}; def wrapper(*args): if args not in store: store[args] = func(*args); return store[args]; return wrapper and applies it to get_user(user_id). What would you point out?

Answer

It returns the result correctly, which is good, but it misses functools.wraps, so logs and tracebacks will say wrapper. It accepts only positional arguments: get_user(user_id=5) raises TypeError, a contract change for callers. The cache never evicts, so in a long-running server it grows without limit, and it’s a plain dict, so if get_user returns a mutable object, every caller shares and can modify the cached copy. It also caches forever, which is wrong for user data that can change. Unhashable arguments (a list) raise TypeError when used as part of the key. In practice I’d suggest functools.lru_cache(maxsize=1024), which handles keyword arguments, bounds memory, exposes cache_info() and cache_clear(), and already preserves metadata. For data that changes, a TTL cache or an external cache is the honest fix, because no in-process decorator knows when the database row was updated.

Next steps

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc