httpx vs requests: Choosing an HTTP Client for Async Python

The requests library is the comfortable default for calling APIs from Python, and for synchronous scripts it is still excellent. But the moment your code runs inside an async framework, requests becomes a liability — it blocks the event loop and silently erases the concurrency you adopted async to get. This page resolves one decision: which HTTP client belongs in a commercial Python API that calls other APIs. Part of the Making HTTP Requests with the requests Library guide.

The short version: use httpx.AsyncClient for anything inside an event loop, create exactly one client for the lifetime of the process, set every timeout phase explicitly against your own latency budget, and size the connection pool to what your upstream will actually tolerate. Keep requests for scripts and cron jobs where blocking is free.


The core difference: one execution model versus two

requests has exactly one execution model: synchronous and blocking. Every call sits on the calling thread until the server responds. That is perfectly fine in a script, a one-off data pull, or a thread-per-request server, and it has been the dependable choice for a decade precisely because it does one thing predictably.

httpx offers the same familiar surface in two flavours. httpx.Client is a synchronous client whose method names mirror requests, so most existing code ports with a search-and-replace. httpx.AsyncClient is the async version, and it is the reason the library exists: await hands control back to the event loop while the socket waits, so one loop can hold hundreds of requests in flight at once. That single capability is the whole argument.

Blocking requests versus awaited httpx in one event loop A blocking requests call holds the event loop until it returns so queued requests wait, while httpx AsyncClient yields control and three requests progress concurrently. requests inside async def Request A holds the loop Request B queued Request C queued 1 call in flight per worker httpx.AsyncClient awaited Request A awaits socket Request B runs too Request C runs too pool limit calls in flight

Why requests freezes a FastAPI worker

This is the trap that bites builders new to async. Inside an async def handler in FastAPI, the event loop drives every connection on a single thread. When you call requests.get(...) there, it makes a blocking socket read and never yields. While it waits on the upstream, the whole loop is frozen: no other request in that worker progresses, not even the health check, not even requests that touch nothing but a local cache.

Python
# WRONG: blocks the whole event loop while it waits
import os
import requests
from fastapi import FastAPI

UPSTREAM_URL = os.getenv("UPSTREAM_URL", "https://example.test/data")
app = FastAPI()


@app.get("/bad")
async def bad():
    # This synchronous call stalls every other request in this worker
    resp = requests.get(UPSTREAM_URL, timeout=10)
    return resp.json()

The numbers make it concrete. Take a 120 ms upstream call, the sort of latency a payment or enrichment API gives you on a good day. A worker that blocks on it serves at most 1000 / 120 ≈ 8.3 requests per second, no matter how much CPU you bought. Move the same call to a def handler and FastAPI hands it to the threadpool, which lifts you to roughly the thread count — around 330 requests per second for the default 40 threads, minus the memory and context-switch tax of the threads themselves. Await it through httpx.AsyncClient with a 100-connection pool and the same worker holds 100 calls in flight, a ceiling near 830 requests per second before anything else becomes the bottleneck.

Throughput per worker for a 120 millisecond upstream call Horizontal bars: blocking requests in an async handler reaches 8 requests per second, requests in a threadpool handler reaches 333, and awaited httpx reaches 833. Requests per second, one worker, 120 ms upstream requests, async def 8 rps — loop blocked requests, sync def 333 rps — 40 threads httpx AsyncClient 833 rps — 100 pooled connections 0 400 800

If you are stuck with a blocking client — an SDK that wraps requests and exposes no async surface — do not call it inline. Push it to a thread with anyio.to_thread.run_sync so the loop keeps turning, and treat the thread count as a hard concurrency limit you now have to monitor. That is a workaround, not a design.


The same call in three clients

Here is one GET expressed three ways: synchronous requests, synchronous httpx, and async httpx. The sync versions are nearly identical, which is exactly why the migration is cheap.

Python
import os
import asyncio
import requests
import httpx

UPSTREAM_URL = os.getenv("UPSTREAM_URL", "https://example.test/data")
TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10"))


# 1. Synchronous requests — fine in scripts and threaded servers
def fetch_requests() -> dict:
    resp = requests.get(UPSTREAM_URL, timeout=TIMEOUT)
    resp.raise_for_status()
    return resp.json()


# 2. Synchronous httpx — same shape, drop-in replacement
def fetch_httpx_sync() -> dict:
    with httpx.Client(timeout=TIMEOUT) as client:
        resp = client.get(UPSTREAM_URL)
        resp.raise_for_status()
        return resp.json()


# 3. Async httpx — non-blocking, the right tool inside an event loop
async def fetch_httpx_async() -> dict:
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        resp = await client.get(UPSTREAM_URL)
        resp.raise_for_status()
        return resp.json()


# Concurrency is where async pays off: these calls overlap instead of queueing
async def fetch_many(client: httpx.AsyncClient, urls: list[str]) -> list[httpx.Response]:
    sem = asyncio.Semaphore(int(os.getenv("HTTP_FANOUT", "20")))

    async def one(url: str) -> httpx.Response:
        async with sem:
            return await client.get(url)

    return await asyncio.gather(*(one(u) for u in urls))

Note the semaphore in fetch_many. A bare asyncio.gather over a list you do not control is how a fan-out becomes an accidental denial-of-service against your own supplier — the pool queues the excess, every queued call still counts against its pool timeout, and you get a burst of PoolTimeout errors that look like an upstream outage but are entirely self-inflicted. Bound the fan-out explicitly and the failure mode disappears.


Timeouts and pools: the settings that actually matter

Both libraries pool connections, but the defaults pull in opposite directions. With requests you must create a Session to reuse TCP connections; a bare requests.get opens a fresh connection every time. httpx pools automatically whenever you reuse a client instance, and exposes the pool through httpx.Limits.

The bigger edge is timeouts. A bare requests.get has no timeout at all by default, so one hung upstream can pin a worker until the process is restarted — still one of the most common causes of a small API going dark. httpx ships a 5-second default and splits it into four independent phases: connect, write, read, and pool acquisition. Independent is the word to internalise. The phases do not share a budget, so a client configured with connect 5, write 10, read 10 and pool 5 can legitimately spend 30 seconds on a single attempt, and if you then wrap it in retries with tenacity you have quietly signed up for a two-minute worst case on a request your customer expects in under a second.

Python
import os
import httpx

CONNECT_T = float(os.getenv("HTTP_CONNECT_TIMEOUT", "2.0"))
READ_T = float(os.getenv("HTTP_READ_TIMEOUT", "4.0"))
WRITE_T = float(os.getenv("HTTP_WRITE_TIMEOUT", "2.0"))
POOL_T = float(os.getenv("HTTP_POOL_TIMEOUT", "1.0"))
MAX_CONN = int(os.getenv("HTTP_MAX_CONNECTIONS", "100"))
MAX_KEEPALIVE = int(os.getenv("HTTP_MAX_KEEPALIVE", "20"))

timeout = httpx.Timeout(connect=CONNECT_T, read=READ_T, write=WRITE_T, pool=POOL_T)
limits = httpx.Limits(
    max_connections=MAX_CONN,
    max_keepalive_connections=MAX_KEEPALIVE,
    keepalive_expiry=float(os.getenv("HTTP_KEEPALIVE_EXPIRY", "20")),
)

Set the pool timeout low and deliberately. It is the only one of the four that reports a problem on your side of the wire: a PoolTimeout means your own concurrency exceeded max_connections, and a one-second ceiling turns that into a fast, obvious error instead of a slow mystery. Keep max_keepalive_connections well below max_connections too, because idle sockets held open against an upstream that recycles them produce sporadic read errors on the first byte of a reused connection.

Per-phase httpx timeouts add up to the real ceiling A timeline comparing loose per-phase timeouts totalling thirty seconds per attempt with a budgeted configuration totalling three and a half seconds. Loose phases: pool 5 + connect 5 + write 10 + read 10 pool 5s connect 5s write 10s read 10s 30 s worst case per attempt Budgeted for a 3 s customer promise 3.5 s total: 0.5 / 1 / 0.5 / 1.5 0s 10s 20s 30s Each phase has its own timer — the ceiling is their sum, not the largest one.

Client lifetime: the mistake that costs 90 ms per call

Creating a client inside the handler is the single most common way builders throw away everything httpx just gave them. Every async with httpx.AsyncClient() as client: inside a route means a fresh DNS lookup, TCP handshake and TLS negotiation on every request — call it 90 ms against a TLS endpoint on another continent, on top of the 120 ms the upstream actually needs. Worse, it also destroys the pool limits, because ten concurrent handlers now hold ten independent pools and your max_connections=100 has quietly become 1,000 as far as the upstream is concerned.

Build the client once in the lifespan and hang it off application state.

Python
import os
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Request

BASE_URL = os.getenv("UPSTREAM_BASE_URL", "https://example.test")
API_KEY = os.environ["UPSTREAM_API_KEY"]


@asynccontextmanager
async def lifespan(app: FastAPI):
    async with httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"Authorization": f"Bearer {API_KEY}"},
        timeout=httpx.Timeout(connect=2.0, read=4.0, write=2.0, pool=1.0),
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
        http2=os.getenv("HTTP2_ENABLED", "false").lower() == "true",
    ) as client:
        app.state.http = client
        yield


app = FastAPI(lifespan=lifespan)


@app.get("/items/{item_id}")
async def get_item(item_id: str, request: Request):
    client: httpx.AsyncClient = request.app.state.http
    resp = await client.get(f"/items/{item_id}")
    resp.raise_for_status()
    return resp.json()

One client per process, created inside the running loop, closed on shutdown. Because AsyncClient binds to the loop that created it, a module-level client built at import time will work in development and then fail with cryptic loop errors under a multi-worker launch — one more reason the lifespan is the right home. If you run multiple workers, remember the pool is per process: four workers with max_connections=100 present 400 sockets to the upstream, which is worth checking against your supplier's limits before you scale the worker count in Uvicorn or Gunicorn.

Handshake cost of a per-request client versus a shared client Three sequential requests each pay a ninety millisecond handshake with a per-request client, totalling 630 milliseconds, while a shared client pays it once for a total of 450 milliseconds. New AsyncClient per request req 1 TLS 90ms GET 120ms req 2 TLS 90ms GET 120ms req 3 TLS 90ms GET 120ms 630 ms of wall clock One client for the process lifetime req 1 TLS 90ms GET 120ms req 2 GET 120ms keep-alive reuse req 3 GET 120ms keep-alive reuse 450 ms of wall clock

Failure modes async introduces that requests never had

Blocking code fails in boring ways. Async code fails under load, so budget an afternoon for these four.

Redirects are off by default. requests follows redirects on GET; httpx does not follow them anywhere unless you pass follow_redirects=True. A port that "works" but suddenly returns empty 301 bodies is almost always this.

Exception names changed. requests.exceptions.HTTPError becomes httpx.HTTPStatusError, and requests.exceptions.RequestException becomes httpx.HTTPError. Any except clause you miss turns a handled upstream failure into a 500 for a paying customer.

Pool exhaustion looks like an upstream outage. When concurrency exceeds max_connections, calls queue and then raise PoolTimeout. The same shape shows up on the database side, and the diagnosis is identical — see fixing connection pool exhaustion for the counting method.

Real concurrency finds your supplier's ceiling. Fifty overlapping calls where you used to send one at a time is exactly how a quiet integration starts returning 429s; pair the migration with rate limiting practices and read debugging 429 errors before you raise the fan-out.

One genuine bonus: httpx streams properly. client.stream("GET", url) gives you an async iterator over chunks, which is how you handle large JSON payloads without loading 200 MB into memory, and it is the same mechanism behind streaming LLM responses through FastAPI.


Migrating without a rewrite

Do it in one pass per module, not as a big-bang branch. The surface is close enough that most edits are mechanical.

requestshttpxWatch for
Session()Client()Same idea, pooled
allow_redirects=Truefollow_redirects=TrueOff by default
HTTPErrorHTTPStatusErrorRename every catch
responses mockrespx mockRewrite fixtures

Start with the test suite, because that is where a silent behaviour change hides. Swap fixtures to respx for mocking external APIs, then exercise your own routes through an async httpx test client so the transport under test is the transport you ship. Add a request hook that emits status, elapsed time and host into your structured logs before the cutover, so you can compare p95 latency across the switch instead of guessing.

Commercially, the migration pays at peak, not on average. One million requests a month is roughly 0.4 per second, and at that rate nobody notices which client you chose. The bill arrives when a customer fires a 5,000-row batch and your peak jumps to 250 requests per second. Blocking at 8.3 rps per worker, that peak needs about 30 workers — fifteen 2 GB instances at roughly $25 each, $375 a month to absorb a burst lasting twenty seconds. The awaited version handles the same peak on one instance with headroom, so you keep two for redundancy and pay $50. That gap is margin, and it grows with every customer who integrates a batch job. Fold it into your cost per API request before you price a tier.


Builder verdict

Use httpx for anything async, full stop. The moment your handlers are async def, a blocking requests call freezes the whole worker and erases the concurrency you paid for, and the fix is a one-line swap to an awaited client. For synchronous scripts and cron jobs, requests remains dependable and there is no urgency to migrate working code. But if you are starting fresh on a commercial API that calls other APIs, standardise on httpx: default timeouts, explicit per-phase control, first-class pooling, HTTP/2 behind one flag, and one client that follows you from prototype to production without a second rewrite. Then spend the hour that matters — one client in the lifespan, four timeout phases sized to your latency promise, a justified pool limit, and a semaphore on every fan-out.

FAQ

Does switching to httpx actually lower my hosting bill? Only if your traffic is bursty, but then it lowers it a lot. A million requests a month averages under one per second and any client copes. The saving shows up at peak: a 250 rps burst against a 120 ms upstream needs roughly fifteen 2 GB instances when each worker blocks at 8.3 rps, versus one or two when calls overlap — about $375 a month against $50.

How risky is this migration on a live API? Low, if you move one module at a time and start with tests. The two changes that bite are redirects being off by default and the renamed exception classes, both of which a decent test suite catches in minutes. Ship behind an env var that selects the client, run both for a day, then compare p95 latency and error rate before deleting the old path.

Can I keep requests anywhere in the codebase? Yes, and you should not force a rewrite. Scripts, cron-driven data pulls, migration one-offs and anything that runs in its own process are fine on requests — blocking a process that has nothing else to do costs nothing. The rule is narrower than "never use requests": never call it inside an event loop.

Is HTTP/2 worth the extra dependency? Usually yes when you fan out many small calls to one host, because multiplexing removes the per-connection ceiling and shrinks your pool needs. It is not free — you add the h2 package and a little CPU per request — and it does nothing for a single large download. Enable it behind an env var, measure p95 on your real traffic, and keep it off if the number does not move.

What breaks first when I raise concurrency? Your supplier's rate limit, almost always before your own hardware. Real concurrency turns a polite serial integration into a burst, and the first symptom is 429s, sometimes followed by a temporary key suspension. Cap the fan-out with a semaphore, honour Retry-After, and confirm your contracted limit before you raise max_connections.

Same topic area:

Adjacent areas: