skip to content

Wrapping Coroutine Functions

Wrapping an async def is not wrapping a function: the wrapper must itself be async and await inside, or hand the coroutine back untouched. Getting it wrong produces a never-awaited object.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

3

Why must a decorator wrapping an `async def` function define its wrapper with `async def` and await inside?

level: juniorimportance: must knowfreq 60%

answer

  1. Calling it does not run it
  2. The wrapper stands in the middle
  3. Nothing ran yet when the wrapper returns
  4. async def wrapper, await inside
  5. RuntimeWarning: never awaited

basics

~20 s

Calling an async def function runs none of its body; it returns a coroutine object. A plain def wrapper only holds that unstarted coroutine, so anything it does around the call observes nothing. The wrapper must be async def and await inside.

solid answer

~40 s

`async def` defines a coroutine function: calling it builds a coroutine object and executes nothing. A decorator rebinds the name to the wrapper, so the wrapper is what callers reach, and it must decide what to do with that coroutine. A plain `def` wrapper can only hand the coroutine straight back untouched, which works as a pass-through but leaves the wrapper no way to time the call, retry it, validate its result or catch its exceptions, because nothing has run by the time the wrapper returns. Worse, if it forgets the `return`, the coroutine is discarded and CPython emits `RuntimeWarning: coroutine ... was never awaited`. The correct shape is an `async def` wrapper that does `result = await func(*args, **kwargs)`, with `functools.wraps` for metadata.

code

python · 23 lines
python
import asyncio
import functools
import time


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

    return wrapper


@timed
async def load_batch(n):
    await asyncio.sleep(0.01)
    return n * 2


print(asyncio.run(load_batch(3)))

go deeper

for a junior

Be ready to say that calling an async def returns a coroutine object and runs nothing, and to write the four-line async def wrapper with await func(*args, **kwargs) inside from memory.

for a middle

Explain the mechanics: @deco rebinds the name, the wrapper receives a coroutine, and only await advances it. Name the never-awaited RuntimeWarning and say when it fires.

for a senior

Show how this fails in production — a decorated path that silently does nothing and reports zero elapsed time — and how you catch it: warnings promoted to errors in tests, -X dev in development.

for a principal

Own the guidance: decide whether the codebase keeps parallel sync and async decorator stacks, and what the review rule is for wrappers around awaitables so this class of silent no-op cannot ship.

## What `async def` actually produces `async def name(...)` does not define something that runs when you call it. It defines a **coroutine function**, and calling a coroutine function builds and returns a **coroutine object** while executing none of the body. The body advances only when something drives it: `await`ing the object, scheduling it, or handing it to `asyncio.run`. Until then it is an inert object holding your arguments and a not-yet-started frame. This is the whole source of the problem, because a decorator sits exactly where that object is produced. ## What the decorator changes `@deco` is sugar for `load = deco(load)`. After decoration the name `load` is bound to whatever `deco` returned, and every caller reaches the original only through that object. So the wrapper now stands between the caller and a factory of coroutine objects, and it has exactly three options — two legal, one a bug. **1. Hand the coroutine back untouched.** `def wrapper(*args, **kwargs): return func(*args, **kwargs)`. This is a genuine pass-through: the caller receives the coroutine and awaits it, and the body eventually runs. But the wrapper cannot do anything *after* the call, because at the moment it returns nothing has happened yet. A `try/except` in the wrapper will never see an exception raised in the body; a `time.perf_counter()` pair around the call measures the cost of constructing a coroutine object, which is microseconds regardless of what the body does; a wrapper that inspects the return value sees a coroutine, not a result. It also changes what the decorated name *is*: by type it is now an ordinary function, so `inspect.iscoroutinefunction` on it reports `False`. **2. Await it inside an `async def` wrapper.** This is the normal answer and the one interviewers want: ```python def timed(func): @functools.wraps(func) async def wrapper(*args, **kwargs): start = time.perf_counter() result = await func(*args, **kwargs) print(time.perf_counter() - start) return result return wrapper ``` Because `wrapper` is itself declared `async def`, the decorated name is still a coroutine function: calling it returns the wrapper's own coroutine, and awaiting that awaits the inner one. Every observation the wrapper wants to make now happens on the far side of `await` — elapsed time is real elapsed time, `try/except` sees exceptions raised in the body, and the returned value is the body's value rather than a coroutine. **3. Drop it.** `def wrapper(*args, **kwargs): func(*args, **kwargs)` — no `return` — or a wrapper that calls the coroutine function inside a `try` and returns something else. Nothing runs, the caller silently gets `None`, and when the abandoned coroutine object is garbage collected CPython emits `RuntimeWarning: coroutine 'load' was never awaited`. ## Why the bug is hard to see The warning fires at collection time, not at call time, so it appears late, often in an unrelated part of the log, and it is a warning rather than an error — a service can run for months quietly doing nothing in a decorated code path. The observable symptom is a function that "succeeds" instantly: the work never happened, so nothing failed and nothing took any time. Turn it into a hard failure while testing by running with `-X dev` or by promoting the category with `-W error::RuntimeWarning`. ## Details worth stating out loud * `functools.wraps` is still required, and it does nothing about awaitability. It copies `__name__`, `__doc__`, `__qualname__`, `__module__` and `__dict__`, and sets `__wrapped__`. It cannot turn a synchronous callable into a coroutine function. * The `async def` wrapper adds one extra coroutine frame per call. That is cheap, but it is not free and it shows up in tracebacks, which now pass through the wrapper before reaching the body. * Awaiting inside the wrapper is transparent to exception propagation: an exception raised in the body surfaces at the `await`, so ordinary `try/except/finally` in the wrapper behaves the way it does in synchronous code. * An `async def` wrapper placed around a *synchronous* callee is the mirror-image mistake: it makes a plain function awaitable and forces every caller to await it. A decorator meant to serve both kinds has to check which kind it received rather than assume. ## The one-line rule The wrapper must be the same kind of callable as the thing it wraps. Wrap a coroutine function with a coroutine function, await inside, and return the awaited result.

  • Does a plain `def` wrapper that returns `func(*args, **kwargs)` without awaiting work at all?
    As a pass-through, yes: the caller receives the coroutine and awaits it, so the body runs. What is lost is everything the wrapper wanted to do around the call. It cannot time it, retry it, inspect the result or catch exceptions from the body, because none of that has happened when the wrapper returns. It also makes the decorated name report as a plain function to `inspect.iscoroutinefunction`.
  • When exactly is the never-awaited RuntimeWarning emitted, and how do you make it fail loudly?
    It is emitted when the coroutine object is destroyed without ever having been awaited, so it surfaces at garbage-collection time rather than at the call site. Run the interpreter with `-X dev`, or promote the category with `-W error::RuntimeWarning` in tests, so the abandoned coroutine raises instead of logging a line nobody reads.
  • Does `functools.wraps` help here, since it sets `__wrapped__` to the original coroutine function?
    No. `functools.wraps` copies metadata — `__name__`, `__qualname__`, `__doc__`, `__module__`, `__dict__` — and records `__wrapped__` so `inspect.signature` can see through the layer. It never changes what kind of callable the wrapper is. Awaitability comes from the wrapper being declared `async def`, not from metadata.

Calling an async def is filling in an order form, not receiving the goods. A synchronous wrapper that times the call is timing how long it takes to fill in the form; await is handing the form over the counter and waiting.

saying these in an interview costs you the question

  • Thinks calling an async def function starts running its body
  • Believes functools.wraps makes a wrapper awaitable
  • Times a coroutine call without awaiting it and reports microseconds
  • Puts try/except around an un-awaited call and expects to catch body errors
  • Treats the never-awaited RuntimeWarning as harmless noise to silence
  • Calls asyncio.run inside a wrapper to avoid making it async

context

open as a page

How do you write one decorator that wraps both plain functions and `async def` functions?

level: middleimportance: should knowfreq 45%

basics

~10 s

Branch once at decoration time on inspect.iscoroutinefunction(func) and return one of two wrappers: an async def one that awaits the call, or a plain def one that does not. Apply functools.wraps in both branches.

open as a page

Why does `inspect.iscoroutinefunction` return False for a decorated async function, and how do you fix the wrapper?

level: seniorimportance: should knowfreq 30%

basics

~20 s

The 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.

open as a page