Skip to main content
If your program spends most of its time waiting — for a database query to come back, a file to finish reading, or an API to respond — asynchronous programming lets you do useful work during that waiting time instead of blocking idle. This is the foundation that makes FastAPI capable of handling thousands of simultaneous requests on a single process.

Concurrency vs Parallelism

These two terms are frequently confused. Understanding the difference tells you which tool to pick.

Concurrency (I/O-bound)

Multiple tasks make progress by taking turns. While one task waits for a response, another runs. All tasks share a single CPU core. This is asyncio’s domain.Use cases: web requests, database queries, file I/O, WebSocket connections.

Parallelism (CPU-bound)

Multiple tasks run at the exact same moment on different CPU cores. Python uses multiprocessing for this because the GIL prevents true thread-level parallelism.Use cases: image processing, matrix math, video encoding, machine learning training.
A web server like FastAPI lives in the concurrency world: each request mainly waits on the database. Async lets it serve request B while waiting for request A’s database query to return.

async def and await

Declaring a function with async def creates a coroutine. Calling it returns a coroutine object — nothing runs yet. To actually execute it and get the result, you await it inside another coroutine.
You can only await inside an async def function. Regular (synchronous) functions cannot use await. If you call asyncio.run() from within a running event loop (e.g., inside a Jupyter notebook), use await main() directly instead.

The Event Loop

The event loop is the scheduler that drives async code. It maintains a queue of coroutines, runs each one until it hits an await, pauses it, and switches to the next ready task. When the I/O operation completes, the original task is resumed.
You start the event loop once at the top level with asyncio.run(). Inside async code, you never call asyncio.run() again — you just await.

Running Tasks Concurrently with asyncio.gather()

The real power of async appears when you fire off multiple tasks at the same time. asyncio.gather() takes several coroutines and runs them concurrently, returning all their results once every one has completed.
Without gather, each await would run sequentially (6 seconds total). With gather, all three run concurrently (3 seconds total — limited only by the slowest task).

asyncio.create_task() for Fire-and-Forget

When you need a task to start immediately without waiting for it right now, use create_task():

async for and async with

Use async for to iterate over an async generator (one that yields items over time, like a streaming database cursor) and async with to use an async context manager.

Real-World Example: Fetch Multiple API Endpoints

FastAPI and Async

FastAPI is built on Starlette, an ASGI framework, and runs on an async event loop. Every async def route handler you write is a coroutine that FastAPI awaits inside its event loop:
You can mix sync and async route handlers in FastAPI. Use async def for handlers that call async libraries (async database drivers, httpx, etc.) and plain def for CPU-bound or purely synchronous operations. FastAPI runs plain def handlers in a thread pool automatically.

Common Pitfalls

await only yields control for async operations. Calling a blocking synchronous function inside an async function (e.g., time.sleep(), synchronous requests.get()) blocks the entire event loop.
Calling an async function without await gives you a coroutine object, not the result. Python 3 will emit a RuntimeWarning.