skip to content

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

level: middleimportance: should knowfreq 45%

answer

  1. Two kinds of callee, one decorator
  2. Decide once, not on every call
  3. Ask the callee what it is
  4. inspect.iscoroutinefunction at decoration time
  5. Two wrapper bodies, both wraps-ed

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.

solid answer

~40 s

The decision belongs at decoration time, not call time. Inside the decorator, ask `inspect.iscoroutinefunction(func)`; if it is true, build and return an `async def` wrapper that does `return await func(*args, **kwargs)`, otherwise return a plain `def` wrapper that calls it directly. Both get `@functools.wraps(func)`, and because the async branch returns a coroutine function, the decorated name keeps reporting truthfully to later introspection. Doing the check inside a single wrapper instead does not work: a synchronous function cannot await, and an `async def` wrapper would force every synchronous caller to await a function that was never asynchronous. Two more kinds exist and deserve a mention — an `async def` containing `yield` is an async generator function, which `inspect.isasyncgenfunction` detects and which must not be awaited, and a callable object hides its kind on `type(obj).__call__`.

code

python · 34 lines
python
import asyncio
import functools
import inspect


def audited(func):
    if inspect.iscoroutinefunction(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            print("start", func.__name__)
            return await func(*args, **kwargs)
    else:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print("start", func.__name__)
            return func(*args, **kwargs)

    return wrapper


@audited
def parse_row(row):
    return row.strip()


@audited
async def store_row(row):
    await asyncio.sleep(0)
    return row.upper()


print(parse_row("  a  "))
print(asyncio.run(store_row("b")))
print(inspect.iscoroutinefunction(store_row))

go deeper

for a junior

Know that the two callee kinds need different wrappers, and that a decorator can inspect what it was handed before deciding which wrapper to return.

for a middle

Write the two-branch decorator on a whiteboard: the inspect.iscoroutinefunction test at decoration time, functools.wraps on both bodies, await only in the asynchronous one.

for a senior

Discuss the failure modes — an async generator function taking the synchronous branch, an upstream pass-through wrapper hiding the callee's kind — and say how you test both paths.

for a principal

Take a position on whether the codebase should have dual-mode decorators at all, versus separate synchronous and asynchronous ones, and what that choice costs during an asynchronous migration.

## The shape of the problem A decorator that adds a cross-cutting concern — auditing, timing, a permission check, an error tag — is usually written once and then applied to whatever is in the module. Some of those callees are plain functions and some are `async def`. The wrapper cannot be neutral about the difference, because a plain wrapper cannot await and an asynchronous wrapper forces its callers to. ## Branch once, at decoration time Decoration happens exactly once per decorated function, at import. That is the right place to pay for the check: ```python def audited(func): if inspect.iscoroutinefunction(func): @functools.wraps(func) async def wrapper(*args, **kwargs): log(func) return await func(*args, **kwargs) else: @functools.wraps(func) def wrapper(*args, **kwargs): log(func) return func(*args, **kwargs) return wrapper ``` Two bodies, one decorator, and the branch is evaluated once no matter how hot the call path is. Each branch is `functools.wraps`-ed independently. Critically, the asynchronous branch returns something that is *itself* a coroutine function, so the decorated name still answers `True` to `inspect.iscoroutinefunction` and any later layer — a second decorator, a dispatcher, a test helper — sees the truth. The tempting alternative, one wrapper that checks at call time, does not survive contact. A plain `def` wrapper cannot `await`, so at best it returns the coroutine for someone else to await, which means the wrapper's own post-call logic never runs. An `async def` wrapper handles both, but only by making every synchronous callee awaitable, which changes the contract of functions that were never asynchronous and breaks their existing call sites. ## What `inspect.iscoroutinefunction` actually answers It reports whether the object it is handed is a coroutine function — a callable whose code carries the coroutine flag. Three refinements matter in practice: * It sees through `functools.partial`, so a partially-applied coroutine function still reports `True`. * It honours the marker set by `inspect.markcoroutinefunction`, added in 3.12, which lets a synchronous callable that returns a coroutine advertise itself as a coroutine function. * It does **not** follow `__wrapped__`. If somebody already wrapped your callee in a plain `def` pass-through, the check reports `False` and your decorator picks the synchronous branch — a correctness bug caused by a layer you did not write. This is exactly why a decorator that returns a synchronous pass-through around a coroutine function should mark it. On Python 3.14 use `inspect.iscoroutinefunction`. `asyncio.iscoroutinefunction` is deprecated in 3.14 and scheduled for removal, so new code should not reach for it. ## The third kind: async generator functions An `async def` that contains `yield` is not a coroutine function at all — it is an **async generator function**. `inspect.iscoroutinefunction` returns `False` for it, and `inspect.isasyncgenfunction` returns `True`. Awaiting the call is wrong; the result is consumed with `async for`, and a wrapper that wants to preserve streaming has to be an async generator itself that yields as it iterates. A two-branch decorator applied to one of these silently takes the synchronous branch and hands the async generator object through untouched, which sometimes works by accident and sometimes does not. If a decorator will be used on streaming callees, test the async generator case explicitly and add a third branch. ## Callable objects and methods `inspect.iscoroutinefunction` on an instance of a class whose `__call__` is `async def` returns `False`, because the instance itself is not a coroutine function; the check has to look at `type(obj).__call__`. The same caution applies to anything wrapped in `classmethod` or `staticmethod` before reaching your decorator: check the callable you actually received, not the name you think you are decorating. ## Whether to do this at all Supporting both kinds in one decorator costs a branch and a duplicated body, and duplicated bodies drift. Two named decorators, one synchronous and one asynchronous, are frequently the better answer in application code — the caller states which world they are in, and each implementation stays simple. The dual-mode form earns its keep in a shared library that cannot know its callees, or when a single decorator is applied across a codebase that is halfway through an asynchronous migration and the churn of renaming every use is not worth it.

  • Why not do the check inside a single wrapper on every call instead?
    Because the wrapper's own kind is fixed when you create it. A plain `def` wrapper cannot await the asynchronous branch, so its post-call logic never runs; an `async def` wrapper can serve both only by making every synchronous callee awaitable, which breaks their existing call sites. The branch also becomes a per-call cost for a fact that cannot change after decoration.
  • Which spelling of `iscoroutinefunction` should new code use on Python 3.14?
    `inspect.iscoroutinefunction`. The `asyncio` spelling is deprecated in 3.14 and slated for removal, and the `inspect` one is the maintained implementation: it sees through `functools.partial` and honours the marker set by `inspect.markcoroutinefunction`. Neither one follows `__wrapped__`, so a pre-existing synchronous pass-through layer will still fool both.
  • What happens when your two-branch decorator is applied to an `async def` that contains `yield`?
    It takes the synchronous branch, because an async generator function is not a coroutine function — `inspect.iscoroutinefunction` is `False` and `inspect.isasyncgenfunction` is `True`. The async generator object is handed back untouched, so the wrapper's after-the-call logic runs before any item has been produced. Streaming callees need an explicit third branch that is itself an async generator.

saying these in an interview costs you the question

  • Checks the callee's kind on every call instead of at decoration
  • Makes one async def wrapper serve synchronous callees too
  • Assumes inspect.iscoroutinefunction follows the __wrapped__ chain
  • Reaches for asyncio.iscoroutinefunction, deprecated in 3.14
  • Treats an async def with yield as a coroutine function
  • Forgets functools.wraps on one of the two branches

context