How to Make Parallel API Calls in Python with asyncio
When a program needs to call an API dozens or hundreds of times — for example, to feed a dashboard or gather data from several endpoints — doing it one call at a time becomes very slow. Parallel API calls in Python solve this problem: instead of waiting for each request to finish before starting the next, we send several at once and the total time drops dramatically.
Prerequisites
- Python 3.8 or later installed.
- Knowing how to make a simple request with
requestsorhttpx. - A terminal to install packages with
pip. - A willingness to experiment: we will use the public testing API
jsonplaceholder.typicode.com.
Step 1: Understand why sequential calls are slow
Imagine you need to fetch 20 tasks from an API. With a normal loop, each request only starts when the previous one finishes. If each takes about 0.2 seconds, the total is around 4 seconds — and it grows as you add more requests.
import time, httpx
inicio = time.perf_counter()
with httpx.Client() as cliente:
for i in range(1, 21):
r = cliente.get(f"https://jsonplaceholder.typicode.com/todos/{i}")
r.json()
print(f"Sequencial: {time.perf_counter() - inicio:.2f} s")
The problem is not that your computer is busy: it is almost always waiting for the network response. That idle time, when nothing is happening, is exactly what we will use to get other requests moving.
Step 2: Install httpx
httpx is a modern HTTP client that works synchronously (like requests) and also asynchronously, which is ideal for parallel requests. Install it with pip:
pip install httpx
Step 3: Create an async function for a single call
In asynchronous code we use async def to define the function and await to wait for the response without blocking the rest of the program. While one request waits for the network, Python is free to handle the others. This function fetches one task and returns the JSON.
async def obter(cliente, id):
r = await cliente.get(f"https://jsonplaceholder.typicode.com/todos/{id}")
return r.json()
Notice that the function receives the cliente as an argument: this way every call reuses the same connection, which is faster than opening a new one each time.
Step 4: Run several calls in parallel with asyncio.gather
asyncio.gather takes several tasks and runs them at the same time, returning the results in the same order you requested them. It is the heart of parallel calls.
import asyncio, time, httpx
async def obter(cliente, id):
r = await cliente.get(f"https://jsonplaceholder.typicode.com/todos/{id}")
return r.json()
async def principal():
inicio = time.perf_counter()
async with httpx.AsyncClient() as cliente:
tarefas = [obter(cliente, i) for i in range(1, 21)]
resultados = await asyncio.gather(*tarefas)
print(f"Paralelo: {time.perf_counter() - inicio:.2f} s")
return resultados
resultados = asyncio.run(principal())
The *tarefas unpacks the list into individual arguments. When you run it, the total time usually goes from several seconds to a few tenths of a second.
Step 5: Limit concurrency with a Semaphore
Sending 500 requests at once can overload the API and trigger the 429 Too Many Requests error. A Semaphore limits how many calls happen at the same time, so you stay a good neighbor and avoid being blocked.
import asyncio, httpx
async def obter(cliente, limite, id):
async with limite:
r = await cliente.get(f"https://jsonplaceholder.typicode.com/todos/{id}")
return r.json()
async def principal():
limite = asyncio.Semaphore(5) # no máximo 5 ao mesmo tempo
async with httpx.AsyncClient(timeout=10) as cliente:
tarefas = [obter(cliente, limite, i) for i in range(1, 21)]
return await asyncio.gather(*tarefas)
resultados = asyncio.run(principal())
print(f"Recebidas {len(resultados)} respostas")
With Semaphore(5), only five requests run at the same time; the rest automatically wait their turn.
Check the result
To confirm everything worked, compare the time printed by the sequential version (Step 1) with the parallel one (Step 4): it should be much lower. Also check that len(resultados) equals the number of requests you made (20) and that no result is empty. If network errors appear, increase the timeout or lower the Semaphore value.
Conclusion
You have learned to turn slow, one-by-one calls into parallel requests that make use of the network waiting time, and to control the load with a Semaphore. The next step is to add error handling — for example, retrying failed requests — and to collect results as they arrive with asyncio.as_completed. Have you thought about how many requests per second your API can handle before it returns 429?