Introduction
Python’s asyncio allows a program to handle many I/O-bound operations without creating a separate operating-system thread for every task. It is especially useful for web applications that spend substantial time waiting for databases, network services, or other external systems.
However, using async def does not automatically make code non-blocking. If an asynchronous application calls blocking functions directly, it can prevent other tasks from progressing.
1. Understand async and await
An asynchronous function is declared with async def, and await suspends its execution while an awaitable operation completes.
import asyncioasync def fetch_data(): await asyncio.sleep(1) return {"status": "ok"}async def main(): result = await fetch_data() print(result)asyncio.run(main())
Here, asyncio.sleep() yields control to the event loop. Other tasks can run during that waiting period.
By contrast, a synchronous sleep blocks the current thread:
import timedef blocking_operation(): time.sleep(1)
Calling blocking_operation() directly from an asynchronous request handler can block the event loop and delay unrelated requests.
2. Use asynchronous libraries for I/O
When possible, use libraries that support asynchronous operations. For example, an asynchronous HTTP client can allow several network requests to overlap while waiting for responses.
import asyncioimport httpxasync def fetch_status( client: httpx.AsyncClient, url: str,): response = await client.get(url) response.raise_for_status() return response.status_codeasync def main(): urls = [ "https://example.com", "https://www.python.org", ] async with httpx.AsyncClient(timeout=10) as client: results = await asyncio.gather( *(fetch_status(client, url) for url in urls) ) print(results)asyncio.run(main())
This example uses an asynchronous client and shares it across the requests. In a production application, reuse clients appropriately rather than constructing a new connection pool for every operation.
3. Offload blocking I/O when necessary
Sometimes a required library is synchronous and cannot easily be replaced. For blocking I/O, asyncio.to_thread() can run the function in a worker thread:
import asyncioimport timedef read_legacy_source(): time.sleep(2) return "completed"async def main(): result = await asyncio.to_thread( read_legacy_source ) print(result)asyncio.run(main())
This prevents the blocking function from occupying the event-loop thread. It does not make the underlying operation intrinsically asynchronous, and thread offloading has overhead.
CPU-intensive work often needs a different strategy, such as a process pool or a separate worker service.
4. Limit concurrency
Launching thousands of tasks simultaneously can overwhelm an upstream API, exhaust connections, or consume excessive memory.
Use a semaphore to limit concurrent work:
import asynciosemaphore = asyncio.Semaphore(10)async def process_item(item): async with semaphore: return await call_external_service(item)
call_external_service() represents an application-specific asynchronous operation. The semaphore limits how many calls enter the protected section concurrently.
Choose limits based on service capacity, connection-pool sizes, rate limits, and observed latency. More concurrency does not always produce better throughput.
5. Handle cancellation and timeouts
Network operations can hang or take longer than the application can tolerate. Configure timeouts and handle cancellation correctly.
import asyncioasync def load_data(): try: async with asyncio.timeout(5): return await perform_async_request() except TimeoutError: return None
This example assumes perform_async_request() is defined elsewhere. In a real application, distinguish a timeout from a valid empty result rather than returning None if that would make failures ambiguous.
Cancellation is also part of normal shutdown behavior. Avoid catching and suppressing cancellation exceptions indiscriminately, because doing so can prevent tasks from terminating cleanly.
6. Do not force async everywhere
Asynchronous code is most useful when operations spend significant time waiting for I/O and concurrency can improve throughput. A simple CPU-only calculation may be clearer as an ordinary synchronous function.
Also remember that a synchronous database driver does not become non-blocking simply because it is called inside async def. Choose compatible libraries and understand the execution model of the framework hosting the code.
Conclusion
Reliable async Python requires non-blocking I/O, sensible concurrency limits, explicit timeouts, and correct cancellation handling. Start by identifying where the application spends its time, then choose asynchronous libraries or carefully managed offloading where they provide measurable value.