Python's asyncio lets a single thread run many I/O-bound tasks concurrently. You write coroutines with async def, schedule them with await, and run them with asyncio.run. This lesson covers the core model, the event loop, common mistakes, and aiohttp for real network code.
async def and the coroutine
A function defined with async def is a coroutine function. Calling it returns a coroutine object, not the result. To get the result you await it. await suspends the current coroutine until the awaited coroutine finishes, letting the event loop run other work in the meantime.
import asyncio
async def greet(name: str) -> str:
await asyncio.sleep(0.1)
return f"Hello, {name}"
async def main():
print(await greet("Asha"))
asyncio.run(main())
Run coroutines concurrently with gather
asyncio.gather schedules multiple coroutines and waits for all of them. The total time is the longest single coroutine, not the sum. This is the easiest way to fan out a small number of independent I/O tasks.
import asyncio
async def fetch(url: str) -> int:
await asyncio.sleep(0.1)
return len(url)
async def main():
urls = ["https://a.com", "https://b.com", "https://c.com"]
results = await asyncio.gather(*(fetch(u) for u in urls))
print(results) # [16, 16, 16] -- ran concurrently, total ~0.1s
asyncio.run(main())
TaskGroup for structured concurrency (3.11+)
asyncio.TaskGroup is the modern, structured way to spawn tasks. It guarantees that if one task fails, all sibling tasks are cancelled and the exception is re-raised. Prefer TaskGroup over bare gather when you need reliable failure handling.
import asyncio
async def work(i: int) -> str:
await asyncio.sleep(0.05 * i)
return f"done {i}"
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(work(i)) for i in range(1, 4)]
print([t.result() for t in tasks])
asyncio.run(main())
aiohttp for real HTTP concurrency
The standard library's urllib is synchronous. aiohttp is the go-to async HTTP client. Use it inside an async function and let asyncio.gather run many requests concurrently. The CPU stays cool because nothing blocks.
import aiohttp, asyncio
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = ["https://example.com", "https://example.org"]
async with aiohttp.ClientSession() as session:
pages = await asyncio.gather(*(fetch(session, u) for u in urls))
for u, body in zip(urls, pages):
print(u, len(body))
asyncio.run(main())
Don't use asyncio for CPU work
asyncio is for I/O concurrency. It does not make CPU-bound code faster. Use multiprocessing or concurrent.futures.ProcessPoolExecutor for CPU work. Mixing them in one program is fine, but be clear about which is which.
import concurrent.futures, math
def is_prime(n: int) -> bool:
if n < 2: return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0: return False
return True
with concurrent.futures.ProcessPoolExecutor() as ex:
primes = list(ex.map(is_prime, range(10_000, 10_100)))
print(sum(primes))
Common pitfalls with async
Three mistakes show up in every async codebase. First, calling an async function without await runs the coroutine but discards the result, and Python issues a RuntimeWarning: coroutine was never awaited. Always await the call, or hand it to asyncio.gather or asyncio.create_task if you want to schedule it. Second, mixing blocking I/O (time.sleep, requests.get, file reads) inside an async function halts the event loop and cancels the concurrency you were trying to achieve. Use asyncio.sleep instead of time.sleep, and reach for aiohttp instead of requests inside async code. Third, swallowing CancelledError silently breaks graceful shutdown. Catch only the specific exceptions you mean to handle, and re-raise CancelledError so callers can stop the task cleanly.
Comparison with JavaScript Promises and C++ coroutines
If you come from JavaScript, Python's asyncio looks and feels similar to Promise-based code. await, async, and the event loop all map to concepts you already know. The biggest difference is that JavaScript's Promise.then is fire-and-forget, while Python's asyncio.create_task returns a Task you can await or cancel later. JavaScript has no equivalent of TaskGroup; the closest is Promise.allSettled, which collects results without short-circuiting. C++20 coroutines are a low-level language feature; you write an awaiter and a promise type by hand, which is more flexible but far more verbose. asyncio's design is higher-level and opinionated, which is what makes it approachable but also what makes it hard to drop into an existing sync codebase without refactoring the calls.
Best practices for asyncio in 2026
Three habits keep async code maintainable. First, set a semaphore when fan-out is large. asyncio.Semaphore(50) limits concurrent in-flight requests to fifty so a slow downstream cannot be crushed by a thousand coroutines all blocked on the same backpressure. Second, use asyncio.timeout in 3.11+ to bound how long a single await can take; the older asyncio.wait_for has subtle cancellation semantics that bite in nested contexts. Third, when you need to bridge sync and async, run the sync function in a thread pool via asyncio.to_thread, which is the modern equivalent of run_in_executor. With these habits, async code scales to thousands of coroutines per process without exhausting file descriptors or surprising the runtime.
Summary
Use async def for coroutines and await to suspend. Use asyncio.gather for simple concurrency and TaskGroup for structured concurrency. Use aiohttp for real network code. Reach for multiprocessing when the work is CPU-bound, not asyncio. The next lesson covers typing, type hints, and the typing module that makes Python code easier to refactor.