Skip to content

asyncio Basics

asyncio runs on an event loop: a scheduler that keeps a queue of coroutines and callbacks, executes them one at a time, and switches between them whenever one suspends at an await expression.

No OS threads are created. The concurrency is cooperative — a coroutine must voluntarily yield control with await for other coroutines to progress.

import asyncio
async def say(message: str, delay: float) -> None:
await asyncio.sleep(delay)
print(message)
async def main() -> None:
await say("Hello", 0.1)
await say("World", 0.1)
asyncio.run(main())
# Hello
# World

In this sequential example, say("World", …) does not start until say("Hello", …) finishes.

A function defined with async def is a coroutine function. Calling it does not execute the body — it returns a coroutine object. The body only runs when the coroutine is awaited or scheduled on the event loop.

import asyncio
async def greet(name: str) -> str:
return f"Hello, {name}!"
# Calling greet() returns a coroutine object — nothing runs yet
coro = greet("Python")
print(type(coro)) # <class 'coroutine'>
# Await it to actually execute the body
result = asyncio.run(greet("Python"))
print(result) # Hello, Python!

The await keyword can only appear inside an async def function. It suspends the current coroutine and yields control back to the event loop. The event loop runs other ready coroutines and returns to this one when the awaited object completes.

import asyncio
async def fetch_data(label: str) -> str:
print(f" {label}: starting fetch")
await asyncio.sleep(0.05) # suspends here
print(f" {label}: fetch done")
return f"data-{label}"
async def main() -> None:
result = await fetch_data("A")
print(result)
asyncio.run(main())
# A: starting fetch
# A: fetch done
# data-A

asyncio.run(coro) creates a new event loop, runs the coroutine to completion, closes the loop, and returns the result. It is the standard entry point for async programs.

import asyncio
async def compute(x: int) -> int:
await asyncio.sleep(0) # yield to event loop once
return x ** 2
result = asyncio.run(compute(7))
print(result) # 49

Call asyncio.run exactly once per program, at the top level. Do not nest calls to asyncio.run — the inner call will raise RuntimeError: This event loop is already running.

asyncio.gather — running coroutines concurrently

Section titled “asyncio.gather — running coroutines concurrently”

asyncio.gather(*coroutines) schedules all coroutines concurrently and awaits all of them. Results are returned in the same order as the input coroutines.

import asyncio
async def step(label: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{label} done"
async def main() -> None:
results = await asyncio.gather(
step("A", 0.3),
step("B", 0.1),
step("C", 0.2),
)
print(results)
# ['A done', 'B done', 'C done'] — always input order
# Total time ≈ 0.3 s, not 0.6 s
asyncio.run(main())

Results are always in input order, regardless of completion order. This mirrors JavaScript’s Promise.all.

Handling failures with return_exceptions=True

Section titled “Handling failures with return_exceptions=True”

By default, if any coroutine raises, gather propagates the first exception and cancels the rest. Pass return_exceptions=True to collect exceptions as values instead (like Promise.allSettled).

import asyncio
async def risky(n: int) -> int:
if n == 2:
raise ValueError(f"n={n} is bad")
return n * 10
async def main() -> None:
results = await asyncio.gather(
risky(1),
risky(2),
risky(3),
return_exceptions=True,
)
for r in results:
if isinstance(r, Exception):
print(f"Error: {r}")
else:
print(f"OK: {r}")
asyncio.run(main())
# OK: 10
# Error: n=2 is bad
# OK: 30

asyncio.TaskGroup (3.11+) is the structured-concurrency alternative to gather. If any task raises, the group cancels all remaining tasks automatically.

import asyncio
async def work(label: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{label} complete"
async def main() -> None:
async with asyncio.TaskGroup() as tg:
task_a = tg.create_task(work("A", 0.2))
task_b = tg.create_task(work("B", 0.1))
# Both tasks are done here; exceptions are re-raised automatically
print(task_a.result())
print(task_b.result())
asyncio.run(main())
What does calling an `async def` function return before you `await` it?
What is the standard entry point for running a top-level async coroutine?
What does `asyncio.gather(a(), b(), c())` guarantee about the order of results?
Which argument to `asyncio.gather` makes it return exceptions as values instead of propagating them?