You are currently viewing When asyncio Actually Helps: A Practical Guide to Async I/O in Python
Photo by Mathews Jumba on Pexels

When asyncio Actually Helps: A Practical Guide to Async I/O in Python

  • Post category:Python
  • Post comments:0 Comments
  • Reading time:4 mins read
  • Post last modified:September 6, 2026

Every few months a Python developer rewrites a slow script to use asyncio, sees no improvement, and concludes async is overhyped. The usual cause isn’t asyncio itself — it’s using it on the wrong kind of workload. Async I/O only helps when your program spends time waiting: for a network response, a database query, a file read. It does nothing for code that’s spending time computing. This guide covers how to tell which kind of bottleneck you have, and the patterns that make async code both fast and correct.

I/O-Bound vs. CPU-Bound: The Distinction That Matters

A Python process is single-threaded by default (ignoring the GIL nuances of thread-based parallelism). When your code is I/O-bound — waiting on a network call, a disk read, or a database round-trip — the CPU sits idle during that wait. asyncio lets your program start another task during that idle time instead of blocking. When your code is CPU-bound — parsing a large file, running numeric computation, image processing — there’s no idle time to fill; the CPU is already busy, and asyncio adds overhead without benefit. For CPU-bound work, look at multiprocessing or a native extension instead.

A Synchronous Bottleneck

Consider fetching data from five APIs sequentially:

import requests

def fetch_all(urls):
    results = []
    for url in urls:
        results.append(requests.get(url).json())
    return results

If each request takes 300ms, five requests take roughly 1.5 seconds — the program is idle almost that entire time, waiting on the network. This is the textbook case for async I/O.

The Async Version

import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)

results = asyncio.run(fetch_all(urls))

Because all five requests are in flight concurrently, the total time is close to the slowest single request — roughly 300ms instead of 1.5 seconds. Note that requests is synchronous and blocking; you need an async-native library like aiohttp or httpx‘s async client for this to work at all.

Common Mistakes

1. Awaiting sequentially instead of gathering

# This is no faster than the synchronous version — each await blocks
# until that specific call finishes before starting the next one.
results = []
for url in urls:
    results.append(await fetch(session, url))

# This runs them concurrently instead.
results = await asyncio.gather(*(fetch(session, url) for url in urls))

2. Calling blocking code inside an async function

A single call to a blocking library (a synchronous DB driver, time.sleep, CPU-heavy computation) inside an async def function blocks the entire event loop, stalling every other concurrent task, not just the one making the call. If you must call blocking code, run it in a thread pool:

import asyncio

async def handler():
    result = await asyncio.to_thread(blocking_function, arg1, arg2)
    return result

3. Forgetting to handle exceptions in gathered tasks

By default, asyncio.gather raises the first exception it encounters, but tasks already running keep running in the background. Use return_exceptions=True when you want all tasks to complete and inspect failures individually:

results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
    if isinstance(result, Exception):
        print(f"Task failed: {result}")

When to Reach for asyncio in a Real Application

  • Web scraping or API aggregation — fetching from many endpoints concurrently.
  • WebSocket servers — handling many open, mostly-idle connections at once.
  • Database-heavy backends — with an async driver (e.g. asyncpg, motor), overlapping query wait time across requests.
  • Not for image processing, data transformation, ML inference, or anything CPU-bound — use multiprocessing, concurrent.futures.ProcessPoolExecutor, or a compiled extension there instead.

Conclusion

asyncio is a tool for one specific problem: overlapping wait time across many I/O operations. Applied to that problem, it can turn a multi-second sequential workload into something close to the duration of a single request. Applied to CPU-bound code, it changes nothing except adding complexity. Before reaching for async def, profile first and confirm your bottleneck is actually a wait, not a computation.

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted