2026, Jan 09 01:00
Don't hide await in a Python decorator: understanding asyncio semantics and safe fire-and-forget scheduling
Learn why hiding await in a Python decorator fails (SyntaxError), how asyncio scheduling works, and when to use fire-and-forget create_task patterns.
Hiding await behind a decorator looks tempting when your codebase is full of async def. But there is a hard boundary in Python’s async model: await is an expression that only works inside an async function. Trying to sprinkle it in via a regular decorator won’t compile and, more importantly, sidesteps essential control over concurrency.
Problem
The idea is to wrap coroutine functions so they can be called without writing await by pushing the await into a decorator. Conceptually it might look like this:
import asyncio
def add_await(f):
def inner(*a, **kw):
return await f(*a, **kw)
return inner
@add_await
async def do_io(t):
await asyncio.sleep(t)
do_io(5)
What actually goes wrong
This approach won’t work. You cannot place an await inside a non-async function. The interpreter will fail fast with a SyntaxError before your code ever runs. The reason is not stylistic; await is part of the language semantics for asynchronous code and only makes sense within an async def body.
There is also a broader point. Using await is a deliberate act that gives you control over when coroutines are created, scheduled, and awaited, including composing them with gather or equivalent constructs. That control is fundamental to writing correct asyncio code.
So what should you do?
The straightforward answer is: keep await where it belongs. The convenience of hiding it is not worth the loss in clarity and control. As for performance, worrying about the slight overhead of a decorator in Python is a non-issue compared to getting the concurrency model right.
If you are exploring alternatives, note that different async libraries take different approaches to ergonomics. For example, the trio project offers create_task-like patterns that unify how you kick off work, but you still use await. The need to await does not go away.
Another direction is bridging async and sync code. It’s possible to craft async_to_sync or sync_to_async call paths that reuse the same event loop, even when crossing intermediary synchronous functions. But in the end the top-level caller must still await either the wrapper or the object returned by a synchronous function. There is no getting rid of await at the boundary where you need a result.
A pragmatic pattern if you insist on await-less calls
If what you want is to fire off coroutines that don’t return a value and can run concurrently, you can wrap a coroutine function so that calling it schedules a task immediately. This does not await the result and therefore cannot give you the return value, but it can be acceptable for true “fire-and-forget” scenarios where the main task stays alive long enough. If the main code finishes first, the running tasks will be cancelled, which is another reason it is often simpler to just await.
import asyncio
class AutoKick:
live_jobs = set()
def __init__(self, fn):
self._fn = fn
def __call__(self, *args, **kwargs):
loop = asyncio.get_running_loop()
coro = self._fn(*args, **kwargs)
job = loop.create_task(coro)
job.add_done_callback(type(self).live_jobs.remove)
type(self).live_jobs.add(job)
return None # no return value without awaiting
# usage example
@AutoKick
async def ping():
await asyncio.sleep(1)
print("hello world!")
async def driver():
ping() # scheduled without await
print("before hello")
await asyncio.sleep(1.1)
print("after hello")
asyncio.run(driver())
This demonstrates that the decorated function schedules work on the current event loop and returns immediately. The output will show the interleaving: a message printed before the scheduled task completes, then the task’s output, then the final message after a small delay.
There are related variations. One can bind tasks to a TaskGroup so background work is guaranteed to finish before leaving the context, but doing that transparently inside a decorator becomes considerably more complex, since it would have to discover the active task group in the caller’s context. It’s also possible to attach callbacks that place results into a queue so you can retrieve them later. All of these patterns presuppose a solid grasp of the asyncio execution model, and once you have that, explicit await tends to feel like an advantage rather than a burden.
Why this matters
Explicit awaits document the scheduling points in your program, control when concurrency happens, and make composition with gather or equivalent mechanisms straightforward. Removing await hides key decisions about ordering, lifetimes, and cancellation. That loss of transparency is often where subtle bugs creep in.
Takeaway
If you’re calling async functions from async code, embrace await. It is the right tool and the language enforces it for good reasons. If you genuinely need to launch background tasks without awaiting results, use a scheduling wrapper like the example above with full awareness that you won’t get return values and that tasks may be cancelled if the main flow ends first. For anything more involved, keep the await in place and leverage the usual concurrency primitives; the clarity and control pay off immediately.