Async Python for API Calls: asyncio and httpx vs requests
Suppose you need to call an API fifty times. Maybe it is the weather for fifty cities, or profile data for fifty user IDs. You reach for requests, write a loop, and watch it crawl. Each call waits for the previous one to come back before the next one starts, so the total time is the sum of all fifty round trips.
Here is the thing worth noticing: almost none of that time is your program doing work. It is your program waiting on the network. The request goes out, and Python sits idle until the response arrives. Multiply that idle stretch by fifty and you have a script that takes far longer than it should.
This post shows how to overlap that waiting. You will see why requests runs one call at a time, how asyncio and httpx let a batch of calls wait together, and -- just as important -- when the extra complexity of async is not worth it.
Why requests is synchronous
The requests library is synchronous, which means it blocks. When you call requests.get(url), that line does not return until the full response has come back. Nothing else in your program runs while it waits. For a single call this is exactly what you want, because the next line genuinely needs the result.
The trouble starts when you have many calls. Put them in a loop and each one blocks until it finishes, so they run strictly one after another.
import requests
urls = [f"https://api.example.com/city/{name}" for name in cities]
results = []
for url in urls:
response = requests.get(url, timeout=10)
results.append(response.json())
If each call takes about half a second, fifty of them take about twenty-five seconds, because the total is the sum of every wait. The CPU is mostly idle the whole time. That idle waiting is exactly the slack async is designed to reclaim.
Enter httpx
The tool we will use is httpx, a modern HTTP client whose interface is deliberately close to requests. If you know one, you mostly know the other. The difference that matters here is that httpx also supports async, which requests does not.
Install it with pip install httpx. Used synchronously, it looks almost identical to the code you already write, so httpx.get(url) behaves much like requests.get(url).
import httpx
response = httpx.get("https://api.example.com/data", timeout=10)
print(response.json())
On its own this buys you nothing over requests. The payoff comes from the async client, and to use that we need a little asyncio.
Just enough asyncio
You do not need a full tour of async Python to make concurrent API calls. Three pieces carry most of the weight.
async defdefines a coroutine: a function that can pause partway through and hand control back rather than running start to finish in one go.awaitis where it pauses. When a coroutine awaits something slow, like a network response, it yields control so other coroutines can run while it waits.asyncio.run(main())starts the event loop, the scheduler that runs your coroutines and switches between them whenever one is waiting.
The mental model is simple. While one call is parked on await waiting for bytes from the network, the event loop is free to start or resume another. That overlap is the entire trick.
Making calls concurrently
Now the payoff. We open an httpx.AsyncClient, launch every request without awaiting them one at a time, and then await them together with asyncio.gather.
import asyncio
import httpx
urls = [f"https://api.example.com/city/{name}" for name in cities]
async def main():
async with httpx.AsyncClient(timeout=10) as client:
responses = await asyncio.gather(*[client.get(u) for u in urls])
return [r.json() for r in responses]
results = asyncio.run(main())
The key line is the asyncio.gather call. Each client.get(u) creates a coroutine, and gather schedules all of them on the event loop at once. As each one hits the network and starts waiting, the loop moves on to the next, so the calls overlap their waiting instead of queuing behind each other.
The effect on timing is the whole point. Instead of the sum of all fifty waits, the batch finishes in roughly the time of the slowest single call, because they were all waiting at the same time. The more calls you have and the more of each call is pure network wait, the bigger the difference.
The mistake that makes async pointless
There is one error that quietly undoes all of this, and it is easy to write by accident.
async def main():
async with httpx.AsyncClient(timeout=10) as client:
results = []
for u in urls:
response = await client.get(u) # awaits one at a time
results.append(response.json())
return results
This is valid async code, and it is no faster than the synchronous loop. The await inside the loop means each call is fully awaited before the next one starts, so they still run one at a time. You have paid for async and gained nothing.
Awaiting inside a loop is still sequential
If you await each call before starting the next, the event loop never gets two requests waiting at once, so there is nothing to overlap. The signature is async code that runs no faster than the plain requests version. The fix is to start all the calls first and await them together with asyncio.gather (or asyncio.as_completed, or a task group), so their network waits run in parallel.
When async is worth it (and when it is not)
Async is not a free speed-up you sprinkle on everything. It helps in one specific situation and adds friction everywhere else.
Async helps I/O-bound concurrency, not CPU work
The win comes from overlapping waiting, so it only appears when your program is I/O-bound and doing many calls at once: lots of independent requests, each spending most of its time idle on the network. Async does not make CPU-bound work (parsing, number crunching, image processing) any faster, because there is no idle time to reclaim. And for a handful of calls, the added complexity of coroutines and an event loop rarely pays for itself.
- Reach for async when you have many independent I/O-bound calls to make and the wall-clock time of running them one at a time actually hurts.
- Stick with
requests(or synchronoushttpx) for one call, a few calls, or a simple script where clarity matters more than shaving seconds.
Timeouts and retries still matter
Running calls concurrently does not remove the need for the basics. Every one of those overlapping requests can still hang, time out, or fail, and a slow call now blocks the whole batch from finishing. httpx supports per-request timeouts (you saw timeout=10 on the client above), and the same reliability thinking applies: set a timeout on every call and retry the failures worth retrying. Our companion guide on timeouts, retries, and backoff covers that mindset in full, and it carries over to async almost unchanged.
Frequently asked questions
Is httpx faster than requests?
For a single call, no, there is no meaningful difference. The advantage of httpx is that it supports async, so when you have many concurrent I/O-bound calls they can overlap their network waits and the batch finishes far sooner. Used synchronously, one call at a time, httpx and requests are comparable.
Do I need async to call an API in Python?
No. For one call or a few calls, synchronous requests or httpx is simpler and perfectly fine. Async pays off when you have many independent calls to make concurrently and the time spent running them one at a time is a real problem.
Can I use requests with asyncio?
Not for true concurrency. requests is synchronous and blocks the event loop, so awaiting it would stall every other coroutine while it waits. Either use the async client in httpx, or run requests calls in a thread pool. For concurrent API calls, httpx is the simpler path.
Mastering APIs with Python
Knowing when to reach for async, and when plain requests is the right call, is the kind of judgement that separates working code from code you can reason about. The full book builds real API clients against live services and teaches the reliability and architecture thinking behind them. 30 chapters, six portfolio projects covering Flask, OAuth, SQLite, Postgres, Docker, CI/CD, and AWS.
Chapter 3 is free to read.