PythonAdvanced

Async Python with asyncio: A Practical Introduction

When async Python actually helps, how async/await and the event loop work together, and the mistakes that silently turn 'async' code back into synchronous code.

DevFieldGuideJuly 11, 2026 (updated July 21, 2026)6 min read
Share:

asyncio gets reached for a lot in Python without a clear sense of when it actually helps — and it's easy to write code that looks async but runs synchronously anyway.

What async actually buys you

Async Python doesn't make individual operations faster, and it doesn't give you true parallelism (that's what multiprocessing is for). What it buys you is concurrency during I/O waits — while one task is waiting on a network response, disk read, or database query, the event loop can run other tasks instead of sitting idle.

That means async is a genuine win for I/O-bound work (API calls, database queries, file operations) and does nothing for CPU-bound work (heavy computation) — an async function doing math in a tight loop blocks the event loop exactly like a synchronous one would.

The basic shape

python
import asyncio
import httpx
 
async def fetch_user(client, user_id):
    response = await client.get(f"/api/users/{user_id}")
    return response.json()
 
async def main():
    async with httpx.AsyncClient() as client:
        user = await fetch_user(client, 42)
        print(user)
 
asyncio.run(main())

async def marks a function as a coroutine — calling it doesn't run it, it creates a coroutine object that needs to be awaited or scheduled. await yields control back to the event loop while waiting on the awaited operation, letting other coroutines run in the meantime.

Coroutine startsHits an await on I/O
Yields to event loopDoesn't block the thread
Loop runs other coroutinesWhile the first waits
I/O completesOriginal coroutine resumes

The actual payoff: concurrent requests

python
async def fetch_all_users(client, user_ids):
    tasks = [fetch_user(client, uid) for uid in user_ids]
    return await asyncio.gather(*tasks)

This is where async pays off. asyncio.gather runs all the requests concurrently — instead of waiting for each API call to finish before starting the next (as a synchronous loop would), all the requests are in flight simultaneously, and the total time is roughly the duration of the slowest single request, not the sum of all of them.

The mistake that quietly kills concurrency

python
# Looks async, runs sequentially — the bug
async def fetch_all_users_broken(client, user_ids):
    results = []
    for uid in user_ids:
        result = await fetch_user(client, uid)  # blocks here every time
        results.append(result)
    return results

Awaiting inside a loop, one call at a time, gets you back to sequential execution — each await fully completes before the loop moves to the next iteration. This is a very common way async code ends up with none of async's actual benefit. If you want concurrency, gather the coroutines first, then await them together.

A second common mistake: blocking calls inside async functions

python
async def bad_fetch():
    import requests
    return requests.get("https://api.example.com")  # synchronous, blocks the whole event loop

Using a synchronous library (like requests, or plain file I/O, or time.sleep) inside an async def function blocks the entire event loop — not just that one coroutine, but every other coroutine waiting to run. Every library in the call chain of an async function needs to be async-native (httpx instead of requests, aiofiles instead of built-in file I/O, asyncio.sleep instead of time.sleep) or the concurrency benefit disappears silently, with no error to warn you.

When to reach for it

Async Python earns its complexity when you're making many independent I/O calls that could run concurrently — a web scraper hitting dozens of URLs, a server handling many simultaneous requests, a script fetching from multiple APIs. For a script that makes one API call and does some math with the result, async adds ceremony without adding speed.

TaskGroup: the modern alternative to gather

Python 3.11 introduced asyncio.TaskGroup, which handles a case gather handles awkwardly: what happens to sibling tasks when one of them raises an exception.

python
async def fetch_all_users_modern(client, user_ids):
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_user(client, uid)) for uid in user_ids]
    return [t.result() for t in tasks]

With TaskGroup, if any task raises, the group automatically cancels all remaining sibling tasks and re-raises once everything has actually finished cancelling — a clean, predictable failure mode. gather's default behavior (without return_exceptions=True) similarly propagates the first exception, but doesn't wait for or clearly manage the cancellation of the other still-running tasks the same way — TaskGroup is the newer, generally recommended default on 3.11+ specifically because that cleanup behavior is more explicit and less surprising.

Timeouts: don't let one slow call hang everything

An external API call with no timeout can hang indefinitely, and in an async context, a single stuck coroutine (if awaited directly rather than run concurrently) can hold up everything downstream of it:

python
async def fetch_with_timeout(client, url):
    async with asyncio.timeout(5):
        return await client.get(url)

asyncio.timeout (3.11+) raises TimeoutError if the block doesn't complete within the given duration, which is essential for any external call whose worst-case latency you don't fully control — without an explicit timeout, "the API is slow today" silently becomes "my service is hung today."

Common mistakes

Common mistakes
  • Forgetting to await a coroutine call at all. Python doesn't error immediately — it just creates a coroutine object that never runs, usually surfacing as a confusing "coroutine was never awaited" warning somewhere downstream, or silently missing data.
  • Wrapping CPU-bound work in async def expecting a speedup. Heavy computation (image processing, complex parsing, number crunching) blocks the event loop exactly like synchronous code — asyncio only helps I/O waits; CPU-bound work needs multiprocessing or a separate worker process instead.
  • Creating an httpx.AsyncClient() (or similar) per request instead of reusing one client across many calls. Connection pooling only helps if the client persists — recreating it per-call throws away the exact efficiency async I/O is meant to provide.
  • Not handling exceptions from asyncio.gather() carefully. By default, one failed coroutine cancels the whole gather unless you pass return_exceptions=True — code that assumes all tasks always complete can crash unexpectedly on the first failure in a batch.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Python

View all