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.

Syntaxasync 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · python
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, prefs

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

Example · python
# 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.

Example · python
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

  • Be the first to comment on this lesson.