Asynchronous programming allows a Python program to handle multiple tasks concurrently without blocking — starting a task, moving on to other work while waiting for it to complete, and coming back to handle the result when it is ready. This is particularly valuable for I/O-bound work: making multiple network requests, reading multiple files, or querying multiple databases simultaneously rather than waiting for each to finish before starting the next.
Python's primary tool for asynchronous programming is the asyncio library, introduced in Python 3.4 and significantly improved through subsequent versions. The async/await syntax, added in Python 3.5, makes asynchronous code readable and approachable.
The Problem: Blocking I/O
In synchronous code, each operation waits for the previous one to complete. When the operation involves waiting — for a network response, for a file to load, for a database query to return — the program simply sits idle:
import time
import requests
def fetch(url):
response = requests.get(url) # Blocks here until the response arrives
return response.status_code
start = time.time()
fetch("https://httpbin.org/delay/1") # Wait 1 second
fetch("https://httpbin.org/delay/1") # Wait another second
fetch("https://httpbin.org/delay/1") # And another
print(f"Total: {time.time() - start:.1f}s") # ~3 seconds
Three requests that each take 1 second run sequentially — total time 3 seconds. If they could run concurrently, the total would be closer to 1 second.
Concurrency vs. Parallelism
Before going further, an important distinction:
| Concurrency | Parallelism | |
|---|---|---|
| Definition | Multiple tasks make progress by taking turns | Multiple tasks execute at exactly the same time |
| How | Single thread, cooperative multitasking | Multiple CPU cores/threads |
| Best for | I/O-bound work (waiting on network, files) | CPU-bound work (calculations, data processing) |
| Python tools | asyncio, async/await | multiprocessing, concurrent.futures |
asyncio is a concurrency tool — it does not use multiple threads or CPU cores. It runs on a single thread, switching between tasks when one is waiting for I/O. For CPU-bound parallelism (heavy computation), use multiprocessing.
Core Concepts
Coroutines
A coroutine is a function defined with async def. It can be paused and resumed, allowing other coroutines to run while it waits:
import asyncio
async def greet(name):
await asyncio.sleep(1) # Pause here — let other coroutines run
print(f"Hello, {name}!")
# A coroutine must be awaited or run by the event loop
asyncio.run(greet("Wariz")) # Hello, Wariz! (after 1 second)
Calling greet("Wariz") does not execute the function — it returns a coroutine object. The function only runs when it is awaited or passed to the event loop.
The Event Loop
The event loop is the engine of asyncio. It manages and schedules coroutines, keeping track of which ones are waiting for I/O and which are ready to run. When a coroutine hits an await, it hands control back to the event loop, which can run another coroutine while waiting.
asyncio.run() creates an event loop, runs a coroutine to completion, and closes the loop:
asyncio.run(main()) # Entry point for async programs
await
await pauses the current coroutine until the awaited coroutine (or other awaitable) completes, and returns its result. It can only be used inside an async def function:
async def fetch_data():
data = await get_from_database() # Pause until DB responds
return data
Running Coroutines Concurrently — asyncio.gather()
The real benefit of asyncio appears when running multiple coroutines concurrently. asyncio.gather() runs multiple coroutines and waits for all of them to complete:
import asyncio
async def fetch(url, delay):
await asyncio.sleep(delay) # Simulates a network request
return f"Response from {url}"
async def main():
results = await asyncio.gather(
fetch("https://api-one.com", 1),
fetch("https://api-two.com", 1),
fetch("https://api-three.com", 1),
)
for result in results:
print(result)
import time
start = time.time()
asyncio.run(main())
print(f"Total: {time.time() - start:.1f}s") # ~1 second, not ~3
All three coroutines run concurrently — each waits for 1 second, but they wait at the same time. Total elapsed time is approximately 1 second.
asyncio.create_task()
asyncio.gather() runs coroutines and waits for all results. asyncio.create_task() schedules a coroutine to run soon and returns a Task object immediately — without waiting for it. This is useful when you want to fire off work and check on it later:
async def main():
task1 = asyncio.create_task(fetch("https://api-one.com", 1))
task2 = asyncio.create_task(fetch("https://api-two.com", 2))
# Do other work here while tasks run in the background
print("Tasks started, doing other work...")
result1 = await task1 # Wait for task1
result2 = await task2 # Wait for task2
print(result1)
print(result2)
asyncio.run(main())
Handling Exceptions in Async Code
Exceptions in coroutines propagate normally through await:
async def risky():
await asyncio.sleep(0.5)
raise ValueError("Something went wrong")
async def main():
try:
await risky()
except ValueError as e:
print(f"Caught: {e}")
asyncio.run(main())
Exceptions in gather()
By default, if any coroutine in gather() raises an exception, gather() propagates it immediately (other coroutines are cancelled). To get all results even when some fail, use return_exceptions=True:
results = await asyncio.gather(
fetch("https://good-api.com", 1),
fetch("https://bad-api.com", 1),
return_exceptions=True
)
for result in results:
if isinstance(result, Exception):
print(f"Error: {result}")
else:
print(result)
Timeouts — asyncio.wait_for()
To set a maximum wait time for a coroutine:
async def main():
try:
result = await asyncio.wait_for(
fetch("https://slow-api.com", 5),
timeout=2.0 # Cancel after 2 seconds
)
except asyncio.TimeoutError:
print("Request timed out")
asyncio.run(main())
Async Context Managers
Resources that require setup and teardown in an async context — like async database connections or HTTP sessions — are managed with async with:
import aiohttp # Third-party async HTTP library
async def fetch_url(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
An async context manager implements __aenter__ and __aexit__ instead of __enter__ and __exit__.
Async Iterators and async for
For iterating over asynchronous sequences — data coming in over a network connection, paginated API responses, database result streams:
async def async_range(n):
for i in range(n):
await asyncio.sleep(0.1) # Simulate async data arrival
yield i
async def main():
async for value in async_range(5):
print(value) # 0, 1, 2, 3, 4
asyncio.run(main())
async for works with objects that implement __aiter__ and __anext__.
asyncio vs. Threading vs. Multiprocessing
Python offers three approaches to concurrency and parallelism. Choosing the right one depends on what your code is waiting on:
| Approach | Module | Best for | Notes |
|---|---|---|---|
asyncio | asyncio | I/O-bound work — many network requests, file operations | Single thread, cooperative; cannot use multiple CPU cores |
| Threading | threading | I/O-bound work with blocking libraries | Multiple threads, but the GIL limits true parallelism |
| Multiprocessing | multiprocessing | CPU-bound work — heavy computation | True parallelism across CPU cores; higher overhead |
concurrent.futures — a unified interface
concurrent.futures provides a high-level interface that works with both threads and processes:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
def fetch(url):
return requests.get(url).status_code
# Thread pool — good for I/O-bound work with blocking libraries
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(fetch, urls))
print(results) # [200, 200, 200] — fetched concurrently
ThreadPoolExecutor is useful when you are working with synchronous libraries (like requests) that cannot be used with asyncio. For async-native libraries, use asyncio directly.
A Real-World Pattern: Async HTTP Requests
The most common real-world use of asyncio in Python is making many HTTP requests concurrently. The aiohttp library provides async-native HTTP:
pip install aiohttp
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)
urls = [
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/posts/2",
"https://jsonplaceholder.typicode.com/posts/3",
]
results = asyncio.run(fetch_all(urls))
for result in results:
print(result["title"])
All three requests are made concurrently. If each takes 300ms, the total is ~300ms rather than ~900ms.
Summary
| Concept | What It Is |
|---|---|
async def | Defines a coroutine function |
await | Pauses the coroutine until an awaitable completes |
asyncio.run() | Runs a coroutine as the top-level entry point |
asyncio.gather() | Runs multiple coroutines concurrently, waits for all |
asyncio.create_task() | Schedules a coroutine to run, returns a Task immediately |
asyncio.wait_for() | Runs a coroutine with a timeout |
async with | Async context manager |
async for | Async iteration |
| Event loop | Schedules and runs coroutines on a single thread |
| Coroutine | An async def function that can be paused and resumed |
asyncio vs. threads | asyncio for async-native I/O; threads for blocking libraries |
asyncio vs. multiprocessing | asyncio for I/O-bound; multiprocessing for CPU-bound |