async def and await
async def builds a coroutine, await runs it, and asyncio.run is the one place synchronous code crosses into the loop.
async def fetch(url): ... # from sync code: asyncio.run(fetch(url))async def defines a coroutine function. Calling it does not run the body: it builds a coroutine object and hands it back, much as calling a generator function builds a generator. Something has to drive that object, and await is what drives it.
await
Inside an async def body, await expr runs expr and lets the event loop run other tasks while it waits. It is a syntax error anywhere else. Awaiting consumes the coroutine object: a second await on the same object raises RuntimeError: cannot reuse already awaited coroutine. Call the function again to get a fresh one.
asyncio.run is the boundary
Synchronous code cannot await, so it needs an entry point. asyncio.run(main()) creates a new event loop, runs the coroutine to completion, cancels any tasks still pending, shuts down async generators and the default thread executor, and closes the loop. Call it once, at the top of the program. It replaces this longhand:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()
# the same thing, plus the cleanup the long form forgets
asyncio.run(main())Since 3.14 its argument may be any awaitable, not only a coroutine. Calling it while a loop is already running raises RuntimeError — inside async code you already have a loop, so just await. For several entry points in one process, asyncio.Runner (3.11+) keeps one loop across repeated run() calls.
Example
When to use it
- Turning one blocking script into an async one and needing to know where asyncio.run goes.
- Reading someone else's async codebase and working out why a function returns a coroutine object instead of data.
- Exposing an async API client to a synchronous test suite.
- Debugging a "coroutine was never awaited" warning in CI.
More examples
Sequential awaits are still sequential
await means "wait here until this finishes", so a function full of awaits is as serial as the synchronous version it replaced. Writing async def buys nothing until two things are in flight at once. This is the single most common disappointment in a first async rewrite.
import asyncio
async def fetch_user(client, uid):
r = await client.get(f"https://api.example.com/users/{uid}")
return r.json()
async def profile(client, uid):
# these three awaits run one after another: 3 round trips of latency
user = await fetch_user(client, uid)
orders = await (await client.get(f"/users/{uid}/orders")).aread()
prefs = await fetch_user(client, uid)
return user, orders, prefs
async def profile_concurrent(client, uid):
# started together instead - see the gather lesson
user, prefs = await asyncio.gather(fetch_user(client, uid), fetch_user(client, uid))
return user, prefsA synchronous facade over an async core
How to expose async internals to a CLI, a cron script or a synchronous test suite without making every caller async. Keep exactly one asyncio.run in the process, at the outermost edge; a nested call raises RuntimeError rather than quietly starting a second loop.
# client.py - the library is async inside, but sync callers still exist
import asyncio
import httpx
async def _fetch_all(urls):
async with httpx.AsyncClient() as client:
return await asyncio.gather(*(client.get(u) for u in urls))
def fetch_all(urls):
"""Blocking wrapper for scripts and tests."""
# legal only when no loop is running in this thread; inside async code,
# callers must await _fetch_all directly instead
return asyncio.run(_fetch_all(urls))The async spellings of with and for
Anything that waits gets an async form: async with for resources whose setup and teardown do IO, async for over an async generator or an async iterator. The protocol methods are __aenter__/__aexit__ and __anext__, not the synchronous ones, so a normal context manager cannot be used with async with.
import asyncio
class Connection:
async def __aenter__(self):
await asyncio.sleep(0) # pretend handshake
return self
async def __aexit__(self, exc_type, exc, tb):
await asyncio.sleep(0) # pretend graceful close
return False # do not suppress exceptions
async def rows(self):
for n in range(3):
await asyncio.sleep(0) # one await per batch from the wire
yield {"id": n} # async generator
async def main():
async with Connection() as conn: # calls __aenter__ / __aexit__
async for row in conn.rows(): # awaits between items
print(row)
Discussion