Async Python is not faster Python. It’s concurrent Python — which is a different thing entirely.

The event loop, explained simply

Python’s asyncio event loop runs on a single thread. When an await expression is hit, execution suspends and control returns to the loop, which can then run other coroutines.

This means: async is only useful for I/O-bound work. If your task is waiting for a network response, a database query, or a file read — async lets you do other things during the wait. If your task is computing (CPU-bound), async doesn’t help. You need processes.

The basic pattern

import asyncio
import httpx

async def fetch(url: str) -> str:
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return response.text

async def main():
    urls = ["https://example.com", "https://httpbin.org"]
    tasks = [asyncio.create_task(fetch(url)) for url in urls]
    results = await asyncio.gather(*tasks)
    return results

asyncio.run(main())

Both requests are in-flight simultaneously. With synchronous requests they’d run sequentially.

The mistake everyone makes first

Calling a blocking function inside an async function kills the event loop for every other coroutine:

async def bad():
    time.sleep(2)       # BLOCKS the entire event loop
    data = open("file") # Also blocking

async def good():
    await asyncio.sleep(2)                             # non-blocking
    data = await asyncio.to_thread(open, "file")       # runs in thread pool

asyncio.gather vs asyncio.TaskGroup

gather runs tasks concurrently and collects results. It’s the go-to for a fixed set of tasks.

TaskGroup (Python 3.11+) is structured concurrency — if any task raises an exception, the others are cancelled automatically. Prefer it for complex flows where you need cleanup.

When threads are better

  • You’re calling a blocking library that has no async version
  • You have CPU-light I/O mixed with some CPU work
  • Your team isn’t familiar with async patterns — debugging concurrent async code requires mental overhead

concurrent.futures.ThreadPoolExecutor with asyncio.to_thread gives you threads from async code without rewriting your entire stack.

The rule I follow

If I’m writing a web server or an HTTP scraper, I use async (FastAPI / httpx). If I’m writing a data pipeline or a CLI, I use threads or processes. The performance difference rarely matters for one-off scripts; the complexity cost always does.