httpx
HTTPX is a fully featured HTTP client for Python from the encode team (the people behind Starlette and Uvicorn). It keeps the familiar requests-style API (httpx.get, response.json, sessions-as-clients) and adds the things requests never shipped: a native async API alongside sync, HTTP/2 support, strict timeouts on by default, full type annotations, and the ability to call ASGI or WSGI apps in-process, which makes it the standard test client for FastAPI and Starlette. It sits on httpcore for transport, with certifi, idna, and anyio underneath, and includes an optional command-line client via httpx[cli]. It is the default choice for async Python HTTP.
The best all-round Python HTTP client today and the obvious pick for async code and ASGI testing. The 0.x version number understates its stability, but the missing response-retry story and slow release cadence are real; pin your version and bring your own retries.
Use it if
- You are writing async Python (FastAPI, asyncio services) and need an HTTP client with a real async API instead of running requests in threads
- You are testing an ASGI app; ASGITransport calls FastAPI or Starlette apps in-process with no server, which is exactly how their test docs tell you to do it
- You want requests-like ergonomics but with type annotations, HTTP/2, and timeouts that fail fast by default instead of hanging forever
- You need one client library that does both sync and async with the same API so shared code paths do not fork
- You need built-in retries with backoff on failed responses; httpx only retries connection establishment via its transport, so real retry logic means writing it yourself or adding a third-party wrapper
- You are wary of pre-1.0 churn; after years of development it is still 0.28.x, the last release landed in December 2024, and past minor versions removed arguments (proxies, app) that broke downstream code
- Maximum async throughput is the goal; aiohttp's own benchmarks and independent ones generally show it faster for high-concurrency request storms, and it also gives you a server
- You have a plain synchronous script and requests already works; switching buys you little, and the mountain of requests-compatible examples on the internet keeps working as-is
Setup reality
pip install httpx pulls a small pure-Python dependency set (httpcore, anyio, certifi, idna), no compilation. HTTP/2 is not included by default, you need pip install httpx[http2] and then http2=True on the client, which people forget and then wonder why everything is HTTP/1.1. The 5-second default timeout is the biggest migration surprise from requests: slow endpoints that used to hang now raise, which is good design but breaks naive scripts. Version upgrades within 0.x have removed keyword arguments (0.26 dropped app=, 0.28 dropped proxies= in favor of proxy= and mounts=), so pin the version and read the changelog before bumping.
Patterns
One-off GET requestsimple-get
import httpx
r = httpx.get('https://api.example.com/items', params={'page': 2})
r.raise_for_status()
data = r.json()Top-level httpx.get opens and closes a connection each call; anything beyond a couple of requests should use a Client.
Reuse connections with a Clientclient-reuse
import httpx
with httpx.Client(base_url='https://api.example.com',
headers={'Authorization': f'Bearer {token}'}) as client:
a = client.get('/users/1')
b = client.get('/users/2')The Client keeps the connection pool alive (like requests.Session); create one per app, not per request.
Async requests with AsyncClientasync-requests
import asyncio
import httpx
async def main():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
client.get('https://api.example.com/a'),
client.get('https://api.example.com/b'),
)
return [r.json() for r in results]
asyncio.run(main())Sync and async clients share the same method names; just do not share one AsyncClient across event loops.
POST a JSON bodypost-json
r = client.post('https://api.example.com/items',
json={'name': 'widget', 'qty': 3})
r.raise_for_status()Use json= for JSON, data= for form-encoded bodies; passing a dict to data= silently sends a form, not JSON.
Configure timeouts explicitlytimeouts
timeout = httpx.Timeout(10.0, connect=5.0)
client = httpx.Client(timeout=timeout)
# or disable for a long streaming call
client.get(url, timeout=None)Everything defaults to 5 seconds, unlike requests which waits forever; long downloads or slow APIs need this raised or you get httpx.ReadTimeout.
Handle errors by typeerror-handling
try:
r = client.get(url)
r.raise_for_status()
except httpx.TimeoutException:
... # connect/read/write/pool timeout
except httpx.HTTPStatusError as e:
print(e.response.status_code, e.response.text)
except httpx.RequestError as e:
print(f'network problem talking to {e.request.url}')4xx/5xx responses do not raise on their own; nothing happens until you call raise_for_status().
Stream a large download to diskstreaming-download
with client.stream('GET', 'https://example.com/big.iso') as r:
r.raise_for_status()
with open('big.iso', 'wb') as f:
for chunk in r.iter_bytes(chunk_size=65536):
f.write(chunk)Accessing r.text or r.json() inside a stream block raises unless you call r.read() first; the body is not loaded by default.
Retry failed connections at the transportconnect-retries
transport = httpx.HTTPTransport(retries=3)
client = httpx.Client(transport=transport)This retries connection errors only, never 5xx responses; for response-level retries with backoff use a loop or a library like tenacity.
Enable HTTP/2http2
# pip install 'httpx[http2]'
client = httpx.Client(http2=True)
r = client.get('https://www.example.org')
print(r.http_version) # 'HTTP/2'Without the [http2] extra installed, http2=True raises at import of the h2 machinery; it also only applies to Client, not top-level httpx.get.
Route through a proxyproxy
client = httpx.Client(proxy='http://localhost:8030')
# per-scheme routing uses mounts
client = httpx.Client(mounts={
'http://': httpx.HTTPTransport(proxy='http://localhost:8030'),
'https://': httpx.HTTPTransport(proxy='http://localhost:8031'),
})The old proxies= argument was removed in 0.28; code copied from pre-2024 answers fails with a TypeError.
Test a FastAPI/Starlette app in-processtest-asgi-app
import httpx
from myproject.main import app
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport,
base_url='http://test') as client:
r = await client.get('/health')
assert r.status_code == 200The app= shortcut on the client was removed; you must wrap the app in ASGITransport yourself now.
Authentication on every requestauth
# basic auth
client = httpx.Client(auth=('user', 'pass'))
# custom scheme via a reusable Auth class
class TokenAuth(httpx.Auth):
def __init__(self, token):
self.token = token
def auth_flow(self, request):
request.headers['Authorization'] = f'Bearer {self.token}'
yield request
client = httpx.Client(auth=TokenAuth(token))httpx.Auth subclasses can react to responses (e.g. refresh on 401) by yielding more requests from auth_flow.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| requests | PyPI | Synchronous-only code where ubiquity and unchanging APIs matter more than async or HTTP/2 |
| aiohttp | PyPI | High-concurrency async workloads where raw throughput beats API ergonomics, or you also need the server side |
| urllib3 | PyPI | Low-level control over pooling and retries with a built-in Retry class, no session sugar needed |