Requests vs. HTTPX: Choosing a Python HTTP Client
requests earned its reputation honestly. For a long time, if you needed to call an API, download a file, or glue two services together in Python, requests was usually the obvious answer.
Async web frameworks, high-concurrency services, and I/O-heavy workloads changed what many teams need from an HTTP client. That's where httpx starts to matter.
This post compares requests and httpx from the point of view of a Python developer who already knows requests, wants a clear explanation of what httpx adds, and doesn't want hand-wavy "modernization" advice.
The Champion: requests¶
requests became the standard because it removed friction. Small API. Sensible defaults. Session when you need reuse.
- The API is small and easy to learn.
- The defaults are simple enough for scripts and internal tools.
Sessiongives you connection reuse and shared configuration without much ceremony.- The ecosystem around it is huge: tutorials, examples, blog posts, Stack Overflow answers, and third-party integrations all assume you know
requests.
For synchronous Python code, it's still a good library. A lot of production code doesn't need async support, doesn't need HTTP/2, and doesn't need a broader transport model. In those cases, requests remains a sensible choice.
That's why this comparison is not really about replacing a bad tool. It's about recognizing where the older tool stops fitting as cleanly.
The Challenger: httpx¶
httpx looks familiar on purpose. The synchronous API feels close enough to requests that most Python developers can read it immediately. That compatibility is one of its best features, because it lowers the cost of trying it.
Its two biggest advantages are:
- Native
asyncandawaitsupport throughhttpx.AsyncClient - Optional HTTP/2 support on both sync and async clients
Those aren't cosmetic differences.
Async support means httpx fits naturally into frameworks like FastAPI, Starlette, and any other async application that should not block the event loop on outbound network calls. HTTP/2 support means one client connection can handle multiplexed requests when the remote server supports it, which can matter for high-concurrency workloads.
You opt in with httpx.Client(http2=True) or httpx.AsyncClient(http2=True), and the server still has to support HTTP/2 for you to get it.
httpx also pushes users toward safer networking defaults. The biggest example is timeouts: requests doesn't time out unless you ask it to, while httpx enforces timeouts by default.
Head-to-Head Examples¶
The synchronous code looks almost identical.
A Standard Synchronous GET Request with requests¶
import requests
# A simple GET request with an explicit timeout.
response = requests.get("https://api.github.com", timeout=10)
# Raise an exception for 4xx or 5xx responses.
response.raise_for_status()
# Parse the JSON body into a Python dictionary.
data = response.json()
print(data["current_user_url"])
This is why requests became so popular. It's straightforward and readable.
The Same Synchronous GET Request with httpx¶
import httpx
# The synchronous API is intentionally close to requests.
response = httpx.get("https://api.github.com", timeout=10.0)
response.raise_for_status()
data = response.json()
print(data["current_user_url"])
For simple synchronous calls, the API is close enough that migration often starts with an import swap.
That doesn't mean behavior matches line for line. Redirect handling differs; see the Migration Guide.
An Asynchronous GET Request with httpx and asyncio¶
This is the point where httpx stops being a near-clone and starts doing work requests cannot do on its own.
import asyncio
import httpx
async def fetch_status(client: httpx.AsyncClient, url: str) -> tuple[str, int]:
# The request is awaited instead of blocking the whole program.
response = await client.get(url)
response.raise_for_status()
return url, response.status_code
async def main() -> None:
urls = [
"https://example.com",
"https://foo.com/get",
"https://api.github.com",
]
# One AsyncClient can be reused across many requests.
async with httpx.AsyncClient(timeout=10.0) as client:
results = await asyncio.gather(
*(fetch_status(client, url) for url in urls)
)
for url, status_code in results:
print(f"{url}: {status_code}")
asyncio.run(main())
That example is doing concurrent I/O without spinning up a thread per request. For a small script, you may not care. For a service making dozens or hundreds of outbound calls, you usually do.
Under the Hood¶
The user-facing APIs look similar, but the concurrency model is not.
Concurrency: Thread Pools vs. Event Loops¶
requests is synchronous. Each request blocks the current thread until the network call finishes or fails. If you want concurrency around requests, the usual pattern is to add threads with concurrent.futures.ThreadPoolExecutor, a worker queue, or some higher-level framework.
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url: str) -> int:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.status_code
urls = ["https://example.com", "https://api.github.com"]
with ThreadPoolExecutor(max_workers=8) as pool:
statuses = list(pool.map(fetch, urls))
It works. Sometimes it's the right call. It's also heavier than async I/O when you're waiting on the network more than you're computing.
httpx gives you both models:
httpx.Clientfor synchronous codehttpx.AsyncClientfor async code running on an event loop
With the async client, the event loop can switch to other tasks while one request is waiting on the network. That usually means better scalability for I/O-bound workloads and a much cleaner fit inside async applications.
The short version:
| Need | Better Fit |
|---|---|
| Simple scripts, CLIs, cron jobs, internal tooling | requests or sync httpx |
| Async web apps and high-concurrency outbound I/O | httpx.AsyncClient |
| Existing synchronous codebase with no async stack | requests stays reasonable |
Timeouts: One Library Forces the Conversation¶
This is one of the most important practical differences.
requests doesn't set a timeout by default. If you forget timeout=..., the call can hang indefinitely.
import requests
# This can wait forever if the remote side stalls.
response = requests.get("https://example.com")
httpx takes the opposite approach. It includes timeouts for network operations by default and raises an exception after five seconds of network inactivity unless you configure something else.
import httpx
try:
httpx.get("https://example.com")
except httpx.TimeoutException:
# Default inactivity timeout is five seconds unless you configure otherwise.
raise
Migration Guide¶
If you already have a codebase built on requests, the good news is that the move to httpx is usually incremental rather than surgical.
Start with the Client Objects¶
The first mapping to remember is:
requests.Session()->httpx.Client()requests.get(...)->httpx.get(...)requests.post(...)->httpx.post(...)
Example:
import requests
session = requests.Session()
session.headers.update({"User-Agent": "my-app/1.0"})
response = session.get("https://api.example.com/users", timeout=10)
response.raise_for_status()
import httpx
client = httpx.Client(headers={"User-Agent": "my-app/1.0"}, timeout=10.0)
response = client.get("https://api.example.com/users")
response.raise_for_status()
client.close()
In real code, prefer the context-manager form:
import httpx
with httpx.Client(headers={"User-Agent": "my-app/1.0"}, timeout=10.0) as client:
response = client.get("https://api.example.com/users")
response.raise_for_status()
Watch for Behavioral Differences¶
This is where migrations get real.
- Redirects
requestsfollows redirects by default for most request methods.httpxdoesn't. If your existing code assumes auto-follow behavior, usefollow_redirects=True.
import httpx
with httpx.Client(follow_redirects=True) as client:
response = client.get("https://example.com")
-
Timeouts If your old
requestscode never set timeouts, moving tohttpxmay surface slow or broken dependencies that were already there. That's usually a good thing, but it can surprise you in the first round of tests. -
Sessions and clients If you already rely on
requests.Session, moving tohttpx.Clientis straightforward. If you don't use sessions today, migration is also a good time to start. Reusing a client is better than creating a brand-new connection for every request. -
Async is not a search-and-replace Moving from
requeststo synchttpxis often simple. Moving tohttpx.AsyncClientis a bigger change because the calling code has to become async too. That meansasync def,await, and usually an async-aware application boundary.
A Safe Upgrade Path¶
If you want the lowest-risk path:
- Replace
requestswith synchronoushttpxin a small, well-tested slice of the codebase. - Set explicit timeouts everywhere.
- Audit redirect assumptions.
- Move shared
Sessionusage tohttpx.Client. - Introduce
AsyncClientonly where the surrounding code is already async or where the concurrency gain is worth the architectural change.
That sequence avoids turning a library swap into a full application rewrite.
So with that, my suggestions are straightforward:
-
Stick with
requestswhen the codebase is synchronous and likely to stay that way, when you're writing scripts or internal tools and want a dependency everyone already knows, or when the current workload doesn't justify churn. -
Reach for
httpxwhen you're writing new code and want better timeout defaults, when you need async support, when you want one library for both sync and async calls, when HTTP/2 to the same host matters, or when your service fans out to a multitude of APIs per request and threads are piling up.