How do async and await work in Python?
Short answer
async def creates a coroutine, await pauses it while waiting so other tasks can run, and asyncio.run starts the event loop. It speeds up code that waits on input and output, not CPU-bound work.
The short version
async def defines a coroutine, await pauses it while it waits for something (a network reply, a timer) and lets other tasks run meanwhile, and asyncio.run starts the event loop that drives them. This makes code that spends its time waiting much faster by overlapping the waiting. It does not speed up CPU-bound work, which is limited by the GIL.
A first coroutine
import asyncio
async def fetch(n):
await asyncio.sleep(1) # stand-in for waiting on a network call
return n * 2
async def main():
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
return results
print(asyncio.run(main())) # [2, 4, 6], after about 1 second, not 3
The three fetch calls wait at the same time, so the whole thing takes about one second rather than three. asyncio.gather runs them concurrently and collects the results.
What await actually does
await says "pause here until this finishes, and while paused, let the event loop run something else". So while one coroutine waits on a slow reply, others make progress. This is cooperative: a coroutine only yields control at an await, so a coroutine that never awaits blocks everything.
Why it helps only I/O-bound work
The speed-up comes from overlapping waiting, so it helps when the bottleneck is input and output: network requests, database queries, file access. For CPU-bound work (heavy computation), there is nothing to wait for, and the GIL still allows only one thread of Python at a time, so async gives no benefit. Use processes for CPU-bound work and async for I/O-bound work.
The common mistakes
Calling a coroutine without awaiting it does nothing useful; it just creates a coroutine object and often warns "coroutine was never awaited".
fetch(1) # WRONG: nothing runs, returns a coroutine object
await fetch(1) # correct, inside an async function
And you cannot await at the top level of a script; you need an event loop, which asyncio.run(main()) provides as the entry point.
Running tasks as they are created
asyncio.gather waits for a fixed set. To launch work and manage it, asyncio.create_task schedules a coroutine to run in the background, which you can await later.
task = asyncio.create_task(fetch(1))
# ... do other things ...
result = await task
The async ecosystem
Plain asyncio gives the machinery, but you usually use async libraries built on it: httpx or aiohttp for HTTP, async database drivers, and web frameworks like FastAPI. A key rule is not to mix a blocking call (like ordinary requests or time.sleep) into async code, because it stalls the whole event loop. Use the async equivalent so the loop can keep running other tasks.
Is it worth it
Async adds real complexity, so reach for it when you have many concurrent I/O operations to overlap, such as calling an API hundreds of times. For a handful of calls, or CPU-bound work, simpler approaches (a thread pool, or just sequential code) are often clearer and enough.