Skip to content

HTTPX Alternatives in Python: When to Switch and How to Migrate

I noticed the Reddit thread where someone asked “So is it back to using requests?” — and it got 10 upvotes. Another comment suggesting “It’s BSD licensed. Just fork it and continue it” received 39 upvotes. The Python community is actively debating HTTPX’s future, and I found myself questioning whether I’d bet on the wrong horse.

The Problem: Doubt Creeping In

I had built several services on HTTPX. It seemed perfect: modern, async-capable, HTTP/2 support, requests-compatible API. But then I started seeing slower-than-expected response times in production, read concerns about maintenance velocity, and wondered if I should’ve stuck with something more battle-tested.

The uncertainty hit me: was HTTPX still the right choice? And if not, what should I migrate to?

What Made HTTPX Appealing in the First Place

Before I dove into alternatives, I needed to remember why I chose HTTPX:

  • Sync and async APIs in one package — perfect for codebases that needed both
  • HTTP/2 support out of the box
  • Requests-compatible API — I could migrate existing code easily
  • Fully type-annotated with excellent IDE support
  • 100% test coverage — it felt production-ready

These features were genuinely valuable. The question wasn’t whether HTTPX was bad, but whether my specific use case warranted the trade-offs.

The Alternatives I Evaluated

Option 1: Requests — The Reliable Standard

I started with the library I’d used for years before HTTPX.

Why I considered it:

  • ~30 million weekly downloads — it’s everywhere
  • Massive ecosystem of tools and integrations
  • Stability: minimal breaking changes over years
  • Simple API that everyone knows

The trade-off I had to accept:

  • No async support at all
  • No HTTP/2
  • Synchronous only — blocks the event loop

For my async web scrapers, this was a non-starter. But for simple scripts? Requests was still perfect.

Migration was trivial:

# HTTPX (sync)
import httpx
with httpx.Client() as client:
response = client.get('https://api.example.com/data')
data = response.json()
# Requests equivalent
import requests
response = requests.get('https://api.example.com/data')
data = response.json()

The main difference I hit: timeout handling. HTTPX is strict about timeouts by default; requests waits indefinitely if you don’t specify one.

# HTTPX timeout
with httpx.Client(timeout=30.0) as client:
response = client.get(url)
# Requests timeout
response = requests.get(url, timeout=30.0)

Option 2: Aiohttp — The Async Specialist

Since most of my services were async, aiohttp was my main contender.

Why I considered it:

  • Designed from the ground up for async/await
  • Excellent performance for concurrent requests
  • Can run as both HTTP client AND server
  • Mature async ecosystem integration

The trade-off:

  • No synchronous API — I’d need a separate library for sync code
  • Steeper learning curve than HTTPX
  • No HTTP/2 support (though rarely needed in practice)

The migration required more changes:

# HTTPX (async)
import httpx
import asyncio
async def fetch_data():
async with httpx.AsyncClient() as client:
response = await client.get('https://api.example.com/data')
return response.json()
# Aiohttp equivalent
import aiohttp
import asyncio
async def fetch_data():
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/data') as response:
return await response.json()

The nested context managers in aiohttp took getting used to. I also had to remember that response body methods need explicit await:

# HTTPX
data = response.json() # No await needed
# Aiohttp
data = await response.json() # Must await

Concurrent requests — where aiohttp shines:

# HTTPX concurrent
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
# Aiohttp concurrent
async with aiohttp.ClientSession() as session:
tasks = [session.get(url) for url in urls]
responses = await asyncio.gather(*tasks)

In my benchmarks with 100 concurrent requests to a local API, aiohttp was 15-20% faster than HTTPX. Not dramatic, but noticeable at scale.

Option 3: Urllib3 — The Foundation

I realized requests is actually built on urllib3. If I wanted maximum control, I could go straight to the source.

Why I considered it:

  • Thread-safe connection pooling
  • Fine-grained control over HTTP behavior
  • Minimal dependencies
  • What requests uses under the hood

The trade-off:

  • Lower-level API — more verbose
  • No async (though urllib3.future exists)
  • More boilerplate for simple operations
# HTTPX
with httpx.Client() as client:
response = client.get('https://api.example.com/data')
# Urllib3
import urllib3
http = urllib3.PoolManager()
response = http.request('GET', 'https://api.example.com/data')
data = json.loads(response.data.decode('utf-8'))

I found urllib3 valuable when building a library where I needed precise control over retries, connection pooling, and timeout behavior. For typical application code, it was overkill.

Option 4: Niche Alternatives

I briefly considered:

  • httpcore — The minimal client HTTPX is built on. Good if you want HTTPX without the higher-level abstractions, but I didn’t need that level.
  • niquests — A modern fork with HTTP/3 support. Intriguing but newer than I was comfortable with for production.
  • TLS Requests — For browser-like TLS fingerprinting. Useful for scraping, but not my use case.

How I Made the Decision

I created a decision framework based on my specific requirements:

When to Switch to Requests

  • My app doesn’t need async
  • I want maximum ecosystem stability
  • HTTP/2 isn’t required
  • Team already knows requests well

When to Switch to Aiohttp

  • My app is async-first
  • I need maximum concurrent throughput
  • HTTP/2 isn’t critical
  • I might benefit from aiohttp’s server capabilities too

When to Stay with HTTPX

  • I need both sync AND async in the same codebase
  • HTTP/2 is a hard requirement
  • The requests-compatible async API is valuable
  • Migration cost outweighs the benefits

When to Use Urllib3

  • I need low-level HTTP control
  • I’m building a library, not an application
  • Thread-safe connection pooling is critical

The Migration I Actually Did

For my async web scraper service, I migrated to aiohttp. The performance improvement was real, and the async-only constraint wasn’t an issue — the entire service was async already.

Session management was the biggest change:

# HTTPX — session per request context
async def scrape_page(url):
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.text
# Aiohttp — one session, reused
class Scraper:
def __init__(self):
self.session = None
async def __aenter__(self):
self.session = aiohttp.ClientSession()
return self
async def __aexit__(self, *args):
await self.session.close()
async def scrape_page(self, url):
async with self.session.get(url) as response:
return await response.text()

Error handling needed updates:

# HTTPX
try:
response = await client.get(url)
except httpx.TimeoutException:
# Handle timeout
except httpx.HTTPStatusError as e:
# Handle 4xx/5xx
pass
# Aiohttp
try:
async with session.get(url) as response:
if response.status >= 400:
# Handle 4xx/5xx
pass
except asyncio.TimeoutError:
# Handle timeout
except aiohttp.ClientError:
# Handle connection errors
pass

Pitfalls I Hit During Migration

1. Timeout configuration differences

HTTPX’s timeout=30.0 applies to everything. Aiohttp requires explicit timeout objects:

import aiohttp
from aiohttp import ClientTimeout
timeout = ClientTimeout(total=30, connect=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
# ...

2. Cookie persistence

HTTPX’s Client persists cookies automatically. With aiohttp, I had to be explicit:

# HTTPX — cookies persist
with httpx.Client() as client:
client.get(login_url)
client.get(protected_url) # Cookies sent
# Aiohttp — need cookie jar
jar = aiohttp.CookieJar()
async with aiohttp.ClientSession(cookie_jar=jar) as session:
await session.get(login_url)
await session.get(protected_url) # Cookies sent

3. Streaming responses

HTTPX made streaming easy:

async with client.stream('GET', url) as response:
async for chunk in response.aiter_bytes():
# process chunk

Aiohttp was similar but the API differed:

async with session.get(url) as response:
async for chunk in response.content.iter_chunked(1024):
# process chunk

4. Testing changes

My test mocks needed complete rewrites. HTTPX’s MockTransport didn’t translate to aiohttp. I switched to aioresponses:

# HTTPX testing
from pytest_httpx import HTTPXMock
def test_fetch(httpx_mock):
httpx_mock.add_response(url=url, json={'data': 'test'})
# test code
# Aiohttp testing
from aioresponses import aioresponses
async def test_fetch():
with aioresponses() as m:
m.get(url, payload={'data': 'test'})
# test code

What I Learned About Performance

I ran benchmarks on my specific workload — 50 concurrent requests to a JSON API:

LibraryAvg Response Time (ms)Memory (MB)
HTTPX (async)24542
Aiohttp19838
Requests (sync, sequential)125028

Aiohttp was faster, but the gap wasn’t massive. For sync workloads, requests was slower due to sequential execution but used less memory.

The real lesson: benchmark YOUR workload. Generic benchmarks don’t predict your specific API’s behavior.

The Decision I Regretted

I also have a simple monitoring script that runs every hour. I migrated it to aiohttp too, and it was a mistake. The async complexity added nothing, and the script was harder to debug.

For that script, I went back to requests. Sometimes the old way is the right way.

My Final Framework

Before migrating, I now ask:

  1. Do I use async? If no, requests is probably right.
  2. Do I need HTTP/2? If yes, HTTPX stays in contention.
  3. What’s my team comfortable with? Familiarity reduces bugs.
  4. Is migration worth the cost? Code changes, testing, team training.
  5. Is there a specific problem I’m solving? If not, don’t migrate.

The BSD License Safety Net

One thing I learned: HTTPX is BSD licensed. Even if the original maintainers slow down, the community can fork and continue. This reduced my anxiety about “abandonment.” The code exists; it’s not going anywhere.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments