httpx was built specifically to be requests-compatible where it makes sense, while adding what requests fundamentally can't retrofit: native async support, HTTP/2, and a single, consistent Client class for both sync and async usage. This lesson covers when and why to reach for it.
1Requests-Compatible by Design, With a Deliberate Purpose
httpx's sync API was deliberately designed to closely mirror requests' ā httpx.get(url, timeout=5), response.raise_for_status(), response.json() all work identically in both libraries, and migrating existing synchronous code between them is often close to a drop-in replacement, changing only the import statement in many cases. This design choice was deliberate, not incidental: httpx's authors specifically wanted developers already familiar with requests to be able to adopt httpx with minimal relearning, since the sync use case (the majority of existing HTTP client code) needed no reinvention.
What httpx adds on top of that familiar foundation is precisely what requests, as an older, more foundational library, cannot retrofit without a breaking rewrite: native async/await support (covered in the next section), built-in HTTP/2 support, and a more modern underlying transport architecture (httpcore) that both libraries' authors have discussed as a natural evolution beyond what requests' original architecture can support.
The practical takeaway for choosing between them on a new project: for a purely synchronous codebase with no foreseeable async needs, requests remains a perfectly reasonable, extremely well-established choice. For any project that either needs async HTTP now, or might plausibly need it later, httpx's identical sync API plus native async support makes it the more forward-compatible choice, avoiding a potential future migration entirely.
import httpx
response = httpx.get("https://api.example.com/data", timeout=5)
response.raise_for_status()
data = response.json()
# Nearly identical to the equivalent requests code -- same method names, same patternsNearly identical to requests ā deliberately
2One Client Class, Sync or Async, Same Method Names
The structural centerpiece of httpx's design is offering both httpx.Client (synchronous) and httpx.AsyncClient (asynchronous) with the *exact same method names and overall shape* ā client.get(), client.post(), the same parameter names for headers, timeouts, and query parameters ā differing only in whether those methods are ordinary synchronous calls or coroutines requiring await, tying directly back to the async/await mechanics covered in the Concurrency & Parallelism section.
This parallel design means a developer who already knows httpx's sync Client API has to learn essentially nothing new to use AsyncClient correctly beyond the general async/await mechanics they'd need for any asyncio code ā async with httpx.AsyncClient() as client: response = await client.get(url) follows the identical shape as its sync counterpart, just wrapped in the async/await machinery. This is deliberately different from having to learn two genuinely separate libraries (like the earlier common pairing of requests for sync and aiohttp for async) with different APIs, different configuration patterns, and different quirks to remember.
The practical benefit compounds for a codebase that has both sync and async call sites (a CLI tool with some async internals, or a codebase migrating gradually toward async) ā using httpx for both means the HTTP-calling code looks and behaves consistently everywhere, rather than requiring developers to context-switch between two entirely different libraries' conventions depending on which part of the codebase they're working in.
import httpx
import asyncio
async def fetch_data():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data", timeout=5)
response.raise_for_status()
return response.json()
asyncio.run(fetch_data())Same shape, same names ā sync or async, your choice
3HTTP/2: A Capability requests Structurally Cannot Offer
HTTP/1.1, the protocol version requests (and most of the web historically) has used, has a real limitation for high-concurrency clients: within a single TCP connection, only one request can be in flight at a time (technically, HTTP/1.1 pipelining exists but is poorly supported and rarely used in practice) ā making multiple concurrent requests to the same host requires multiple separate connections, each with its own setup overhead.
HTTP/2 introduces true multiplexing at the protocol level: multiple requests and responses can be interleaved over a *single* underlying TCP connection simultaneously, eliminating the need for multiple connections to achieve request concurrency to the same host, and reducing per-request overhead meaningfully for workloads making many concurrent calls to the same API. httpx.Client(http2=True) opts into this behavior when both the client and the target server support it (falling back to HTTP/1.1 transparently otherwise).
This capability exists in httpx specifically because it was built on httpcore, a newer, more capable underlying transport library designed with HTTP/2 support from the start ā requests' underlying transport (urllib3, historically) was built around an HTTP/1.1 model, and adding genuine HTTP/2 support would require the kind of fundamental architectural change that motivated building httpx as a new library rather than attempting to retrofit requests itself.
import httpx
client = httpx.Client(http2=True)
response = client.get("https://api.example.com/data")
# HTTP/2, when the server supports it, can multiplex requests
# over a single connection more efficiently than HTTP/1.1True multiplexing over one connection ā structurally unavailable in requests
4Step-by-Step Breakdown
requests was never designed for async code ā it's a fundamentally synchronous library. httpx offers nearly the same API, plus the async support requests can't provide.
httpx's SYNC API is deliberately almost identical to requests -- migrating existing code is usually a near drop-in replacement.
The SAME httpx.Client class supports async too -- via AsyncClient, using the exact same method names, just awaited.
Checkpoint: What is the key structural difference between httpx.Client and httpx.AsyncClient?
- āAsyncClient's methods are coroutines that must be awaited, matching Python's async/await syntax; Client's methods run synchronously
- āThey support entirely different, non-overlapping sets of HTTP features
httpx supports HTTP/2 -- a single connection can multiplex multiple concurrent requests, unlike HTTP/1.1's one-request-per-connection-at-a-time model.
Checkpoint: Why is HTTP/2 support (client=httpx.Client(http2=True)) something requests cannot offer?
- ārequests is built on an HTTP/1.1-only underlying transport, with no built-in HTTP/2 support
- āHTTP/2 is not a real, standardized protocol
httpx's sync usage mirrors requests closely; async patterns deserve their own deeper look, which the Async HTTP Clients lesson provides.
Match Real Sync and Async Kwargs. Finish build_request_kwargs(): httpx.Client and AsyncClient accept the exact same arguments.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Choose httpx over requests for any new project, especially if async HTTP is plausible now or later
httpx's sync API is nearly identical to requests', so there's minimal cost to choosing it, while it avoids a potential future migration if async needs emerge.
Use the same httpx library (Client and AsyncClient) rather than mixing requests and aiohttp for sync/async needs
This keeps HTTP-calling code consistent across a codebase's sync and async portions, rather than requiring developers to context-switch between two entirely different libraries' APIs and conventions.
Frequent Bugs
Choosing requests for a new project that later needs async HTTP support, requiring a full migration to a different library (or maintaining two separate HTTP client libraries) rather than simply adding await to already-familiar httpx code.
Default to httpx for new projects, particularly if there is any plausible future need for async HTTP, avoiding a potential future library migration entirely.
Real-World Examples
A Client Class Supporting Both Sync and Async Usage Modes
A library needs to offer both a synchronous and an asynchronous API for the same underlying operations, since some consumers use it from plain scripts and others from an async web framework.
import httpx
class ApiClient:
def __init__(self, base_url: str):
self.base_url = base_url
def get_user_sync(self, user_id: int) -> dict:
with httpx.Client(timeout=5) as client:
response = client.get(f"{self.base_url}/users/{user_id}")
response.raise_for_status()
return response.json()
async def get_user_async(self, user_id: int) -> dict:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{self.base_url}/users/{user_id}")
response.raise_for_status()
return response.json()