Tech Verse Logo
Enable dark mode
Async Python: asyncio Without the Confusion

Async Python: asyncio Without the Confusion

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

The Event Loop Is Just a Single Thread

Most developers coming from multi-threaded environments or synchronous frameworks expect Python's asyncio module to magically run code across multiple CPU cores. It doesn't. At its core, asyncio runs a single-threaded event loop executing tasks cooperatively. The loop tracks suspended tasks and resumes them when network sockets or file descriptors report readiness.

When you call an async def function, Python doesn't execute it immediately. It creates a coroutine object. Nothing runs until that coroutine is scheduled on the event loop with await or asyncio.create_task(). If your task calls a blocking library like requests or time.sleep(), the entire event loop stops. Every other pending request on that worker sits in memory waiting for that one synchronous operation to finish. If your FastAPI worker handles 200 open connections, one sync call freezes all 200 of them.

Concurrency vs Parallelism in Python

It helps to be clear about terms. Concurrency is dealing with lots of things at once. Parallelism is doing lots of things at once. Because Python has the Global Interpreter Lock (GIL), asyncio gives you concurrency, not parallelism. It shines when your process spends 95% of its time waiting on external systems like database queries, third-party REST APIs, or Redis commands.

If you are parsing giant JSON payloads, running machine learning inferences, or processing images, asyncio won't speed up your code. In fact, adding async overhead to heavy CPU calculations makes them slightly slower. For CPU-bound workloads, you need multi-processing, offloading work to background queues like Celery, or handing execution to a daemon written in Rust or Go.

Making Your First Non-Blocking Requests

Here is how you execute three external API calls concurrently in Python 3.11+. We use httpx instead of requests because httpx exposes a native async client that yields control to the event loop while waiting for network responses.

import asyncio
import httpx
import time

async def fetch_service(client: httpx.AsyncClient, url: str) -> dict:
    response = await client.get(url)
    return response.json()

async def main():
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    ]
    
    async with httpx.AsyncClient() as client:
        tasks = [fetch_service(client, url) for url in urls]
        results = await asyncio.gather(*tasks)
        return results

if __name__ == "__main__":
    start = time.perf_counter()
    data = asyncio.run(main())
    duration = time.perf_counter() - start
    print(f"Fetched {len(data)} endpoints in {duration:.2f} seconds")

Running three 1-second delayed endpoints sequentially with standard libraries takes about 3.05 seconds. With httpx and asyncio.gather, execution completes in 1.08 seconds. You get a 3x speedup because the thread suspends execution as soon as client.get emits the HTTP request frame, switching instantly to fire off the remaining outbound calls.

The Silent Production Killer: Sync Calls in Async Functions

The single biggest mistake in async Python is calling synchronous drivers inside an async function. It looks correct because the function signature says async def, but inside, code calls a synchronous library like requests or psycopg2.

Here is what happens in production: your API endpoint gets 50 concurrent hits. Request #1 hits requests.get(). Python blocks the OS thread. Requests #2 through #50 are stalled, even though they sit inside async handlers. Response latency spikes from 30ms to 4000ms under minimal load.

If you must call a synchronous library because an async driver doesn't exist, you must explicitly offload the blocking call to a thread pool using asyncio.to_thread(). Introduced in Python 3.9 and refined in Python 3.11, this utility passes execution to a background worker without blocking the main event loop.

import asyncio
import requests
import time

def blocking_fetch(url: str) -> int:
    # Synchronous call that blocks thread execution
    response = requests.get(url, timeout=5)
    return response.status_code

async def main():
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    ]
    
    # Run synchronous functions safely in separate threads
    tasks = [asyncio.to_thread(blocking_fetch, url) for url in urls]
    results = await asyncio.gather(*tasks)
    print(f"Status codes: {results}")

if __name__ == "__main__":
    start = time.perf_counter()
    asyncio.run(main())
    duration = time.perf_counter() - start
    print(f"Completed safe threaded execution in {duration:.2f} seconds")

By wrapping blocking_fetch in asyncio.to_thread(), the main event loop stays free to process incoming connections while Python manages thread pool workers in the background. Total execution time drops from 3 seconds down to 1.10 seconds.

Error Handling Traps with asyncio.gather

By default, asyncio.gather behaves aggressively when an exception occurs. If one task out of ten raises an unhandled error, gather immediately raises that exception to the caller. But what happens to the other nine tasks? They keep running in the background unmonitored unless you handle them.

To capture errors safely without aborting the remaining batch, set return_exceptions=True inside asyncio.gather. This instructs the loop to treat exceptions as returned values inside the output list rather than throwing them immediately.

async def resilient_fetch_all(urls):
    async with httpx.AsyncClient() as client:
        tasks = [fetch_service(client, url) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        valid_data = []
        for result in results:
            if isinstance(result, Exception):
                print(f"Fetch failed with error: {result}")
            else:
                valid_data.append(result)
        return valid_data

Without return_exceptions=True, a single timeout on a third-party service ruins every concurrent task in that batch. In Python 3.11+, you can also use TaskGroup context managers, which provide structured concurrency by automatically cancelling sibling tasks when one fails.

Choosing Between Async Python and Alternative Stacks

Async Python is a great fit for services aggregating multiple internal microservices, handling persistent WebSocket connections, or making dozens of external API calls per inbound request. But it isn't always necessary.

If you build traditional CRUD applications with heavy database reads and writes, standard synchronous setups like Laravel 12 running on PHP 8.3 or Next.js 16 server actions often deliver simpler code bases. In Laravel 12, running concurrent outbound HTTP calls is handled cleanly with Http::pool(), which uses cURL handles under the hood without forcing your entire application into an async function color paradigm.

When you do pick Python for high-concurrency I/O, commit to non-blocking drivers across your whole stack: asyncpg for PostgreSQL, redis-py's async client, and httpx for HTTP calls. Mixing synchronous drivers into asynchronous loops is the fastest way to wreck service performance.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Profiling Python: Finding the Actual Bottleneck

    Profiling Python: Finding the Actual Bottleneck

    SQLAlchemy 2.0 for Eloquent Developers

    SQLAlchemy 2.0 for Eloquent Developers

    Django vs FastAPI vs Flask: Pick the Right Python Stack

    Django vs FastAPI vs Flask: Pick the Right Python Stack

    Clean Pytest: Fixtures, Parametrisation, and Mocks

    Clean Pytest: Fixtures, Parametrisation, and Mocks

    Async Python: asyncio Without the Confusion

    Async Python: asyncio Without the Confusion

    Python Type Hints and Mypy: Real World Patterns

    Python Type Hints and Mypy: Real World Patterns

    FastAPI for PHP Developers: Core Concepts Mapped

    FastAPI for PHP Developers: Core Concepts Mapped

    Python venv vs uv vs Poetry: Choosing for Production

    Python venv vs uv vs Poetry: Choosing for Production

    Testing LLM Integration Without Flaky CI Runs

    Testing LLM Integration Without Flaky CI Runs

    Integrating Image Generation APIs: Prompts, Ratios, Storage

    Integrating Image Generation APIs: Prompts, Ratios, Storage