Why does `inspect.iscoroutinefunction` return False for a decorated async function, and how do you fix the wrapper?
answer
- The name points at the wrapper now
- Copying metadata is not changing kind
- The check reads the object itself
- A synchronous pass-through hides the coroutine
- inspect.markcoroutinefunction sets the marker
basics
~20 sThe name now points at the wrapper, and a plain def wrapper is a synchronous callable no matter what it returns. functools.wraps copies metadata but not kind. Either make the wrapper async def and await inside, or call inspect.markcoroutinefunction on it.
solid answer
~40 sAfter decoration the name is bound to the wrapper, so introspection describes the wrapper, not the original. `inspect.iscoroutinefunction` answers from the object's own kind — the coroutine flag on its code, plus the marker `inspect.markcoroutinefunction` sets — and it deliberately does not follow the `__wrapped__` chain, so `functools.wraps` cannot rescue it. A plain `def` wrapper that returns the inner coroutine untouched therefore advertises itself as synchronous. Two fixes: make the wrapper `async def` and `await` the inner call, which is right whenever the wrapper wants to observe the result; or, if the pass-through must stay synchronous so the caller receives the callee's own coroutine object, call `inspect.markcoroutinefunction(wrapper)`, available since 3.12, which returns the function and can be used as a decorator. This matters because dispatchers route on that answer.
code
python · 21 linesimport asyncio
import functools
import inspect
def register(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) # hands the coroutine back untouched
return inspect.markcoroutinefunction(wrapper)
@register
async def load_results(batch):
await asyncio.sleep(0)
return len(batch)
print(inspect.iscoroutinefunction(load_results))
print(asyncio.run(load_results(["a", "b"])))go deeper
Remember that a decorator replaces the function object, so tools that ask what a function is are asking about the wrapper, not the original underneath it.
Explain that the check reads the object's own kind and ignores wrapped, and give both fixes: an async def wrapper that awaits, or inspect.markcoroutinefunction on a pass-through.
Show the production consequence — a dispatcher routing on that answer, work silently not running, a rollback path that never executes — and the CI assertions and warning promotion that catch it.
Own the contract: what a shared decorator library must guarantee about the kind of callable it returns, and how that promise is tested so downstream dispatchers can rely on introspection.
## Why the answer flips `@deco` rebinds the name: `load = deco(load)`. Everything that introspects `load` afterwards is introspecting the wrapper. `inspect.iscoroutinefunction` answers a question about the object in front of it — does this callable's code carry the coroutine flag, or does it carry the marker set by `inspect.markcoroutinefunction` — and it unwraps `functools.partial` on the way. What it does **not** do is walk the `__wrapped__` chain that `functools.wraps` leaves behind. That is a deliberate asymmetry with `inspect.signature`, which does follow `__wrapped__`: a signature is a claim about the arguments, which survive wrapping, whereas coroutine-ness is a claim about how the object must be called, which does not. So a wrapper written as a plain `def` that returns `func(*args, **kwargs)` is, correctly, reported as a synchronous function. It returns a coroutine, but so could any other synchronous function, and nothing in the object says otherwise. ## Why it is more than a cosmetic complaint Real code routes on this answer. Test harnesses use it to decide whether to run a callable directly or drive it with a runner. Framework glue uses it to decide between awaiting a handler and offloading it to a worker thread. Plugin registries use it to reject the wrong kind at registration. Make it concrete with a clinical-lab result loader. Its dispatcher is unremarkable: coroutine functions get awaited on the loop, everything else is handed to a thread pool so a blocking driver call cannot stall the loop. Somebody adds an audit decorator whose wrapper is a synchronous pass-through — it registers the callee and returns the coroutine untouched. The loader stages, applies, and on any bad record performs a partial-failure rollback of the batch. After the decorator ships, the dispatcher sees a non-coroutine callable and posts the loader to the thread pool. The thread calls it, receives a coroutine object, sees a truthy return value, and reports success. Nothing was staged, nothing was applied, and the rollback path — which lives inside the coroutine that never ran — never executes. Latency collapses, so the batch now lands comfortably inside its 92nd-percentile budget, which is precisely the signal that should have raised suspicion: the run got faster because it stopped doing anything. The only trace is a `RuntimeWarning: coroutine ... was never awaited` at collection time, on a logger nobody watches. ## The two fixes **Make the wrapper asynchronous.** If the wrapper wants to do anything around the call — time it, retry it, tag its exceptions, validate the result — it must be `async def` and `await` inside. Then it *is* a coroutine function and every check answers `True` with no marker needed. This is the default and should be the first thing you reach for. **Mark the pass-through.** Sometimes the wrapper genuinely has nothing to do after the call, and there is value in returning the callee's own coroutine object rather than a new one wrapping it: it keeps one fewer frame in every traceback and out of every await chain, and it preserves object identity for anything downstream that inspects the coroutine. In that case keep the plain `def` and pass the wrapper through `inspect.markcoroutinefunction`, added in 3.12. It sets the marker, returns the function so it can be used directly as a decorator, and `inspect.iscoroutinefunction` then reports `True`. ```python def register(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return inspect.markcoroutinefunction(wrapper) ``` The marker is a promise, not a check: it says "calling this returns an awaitable". Mark a wrapper that does not, and you have moved the lie one level down. ## Catching it before production does * Assert the property in tests. A decorator's test suite should contain `assert inspect.iscoroutinefunction(decorated)` for an asynchronous callee; it is one line and it pins the behaviour against future edits to the wrapper. * Promote the warning. Running tests with `-W error::RuntimeWarning`, or the interpreter with `-X dev`, turns an abandoned coroutine into a failure at collection instead of a log line. * Be suspicious of decorated paths that got dramatically faster. Work that vanishes is indistinguishable from work that succeeded instantly, and a coroutine that is never awaited is exactly work that vanished. ## The framing to give an interviewer Decoration replaces the object, so it replaces the object's answers about itself. Anything downstream that dispatches on those answers inherits whatever the wrapper claims. Either make the wrapper honest by construction — same kind as the callee — or make it honest by declaration, with the marker.
- `functools.wraps` sets `__wrapped__` to the original coroutine function — why does that not fix the check?Because `inspect.iscoroutinefunction` never follows `__wrapped__`. It reads the coroutine flag on the object's own code and the marker that `inspect.markcoroutinefunction` sets, and it unwraps `functools.partial`, but nothing else. `inspect.signature` does follow `__wrapped__`, which is why signatures survive decoration while coroutine-ness does not — a signature describes arguments, coroutine-ness describes how the object must be called.
- When would you deliberately keep a synchronous pass-through wrapper instead of making it `async def`?When the wrapper has nothing to do after the call — a registry, a tag, an argument rewrite — and you want the caller to receive the callee's own coroutine object. That keeps one fewer frame out of every await chain and every traceback, and preserves the object's identity for anything downstream that inspects it. Then mark it, so introspection still reports the truth.
- How would you catch this class of bug in CI rather than in production?Assert the property directly: for each decorator, apply it to an asynchronous sample callee and check `inspect.iscoroutinefunction` on the result. Alongside that, run the test suite with `-W error::RuntimeWarning` so an abandoned coroutine raises at collection instead of logging. A decorated path whose measured duration suddenly collapses deserves the same suspicion.
saying these in an interview costs you the question
- Expects functools.wraps to preserve coroutine-ness
- Thinks inspect.iscoroutinefunction follows the __wrapped__ chain
- Calls the check cosmetic when dispatchers route on it
- Marks a wrapper that does not actually return an awaitable
- Reads a sudden latency drop as a performance win
- Silences the never-awaited warning instead of finding the wrapper