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 equivalentimport 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 timeoutwith httpx.Client(timeout=30.0) as client: response = client.get(url)
# Requests timeoutresponse = 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 httpximport asyncio
async def fetch_data(): async with httpx.AsyncClient() as client: response = await client.get('https://api.example.com/data') return response.json()
# Aiohttp equivalentimport aiohttpimport 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:
# HTTPXdata = response.json() # No await needed
# Aiohttpdata = await response.json() # Must awaitConcurrent requests — where aiohttp shines:
# HTTPX concurrentasync with httpx.AsyncClient() as client: tasks = [client.get(url) for url in urls] responses = await asyncio.gather(*tasks)
# Aiohttp concurrentasync 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
# HTTPXwith httpx.Client() as client: response = client.get('https://api.example.com/data')
# Urllib3import 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 contextasync def scrape_page(url): async with httpx.AsyncClient() as client: response = await client.get(url) return response.text
# Aiohttp — one session, reusedclass 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:
# HTTPXtry: response = await client.get(url)except httpx.TimeoutException: # Handle timeoutexcept httpx.HTTPStatusError as e: # Handle 4xx/5xx pass
# Aiohttptry: async with session.get(url) as response: if response.status >= 400: # Handle 4xx/5xx passexcept asyncio.TimeoutError: # Handle timeoutexcept aiohttp.ClientError: # Handle connection errors passPitfalls I Hit During Migration
1. Timeout configuration differences
HTTPX’s timeout=30.0 applies to everything. Aiohttp requires explicit timeout objects:
import aiohttpfrom 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 persistwith httpx.Client() as client: client.get(login_url) client.get(protected_url) # Cookies sent
# Aiohttp — need cookie jarjar = aiohttp.CookieJar()async with aiohttp.ClientSession(cookie_jar=jar) as session: await session.get(login_url) await session.get(protected_url) # Cookies sent3. Streaming responses
HTTPX made streaming easy:
async with client.stream('GET', url) as response: async for chunk in response.aiter_bytes(): # process chunkAiohttp was similar but the API differed:
async with session.get(url) as response: async for chunk in response.content.iter_chunked(1024): # process chunk4. Testing changes
My test mocks needed complete rewrites. HTTPX’s MockTransport didn’t translate to aiohttp. I switched to aioresponses:
# HTTPX testingfrom pytest_httpx import HTTPXMock
def test_fetch(httpx_mock): httpx_mock.add_response(url=url, json={'data': 'test'}) # test code
# Aiohttp testingfrom aioresponses import aioresponses
async def test_fetch(): with aioresponses() as m: m.get(url, payload={'data': 'test'}) # test codeWhat I Learned About Performance
I ran benchmarks on my specific workload — 50 concurrent requests to a JSON API:
| Library | Avg Response Time (ms) | Memory (MB) |
|---|---|---|
| HTTPX (async) | 245 | 42 |
| Aiohttp | 198 | 38 |
| Requests (sync, sequential) | 1250 | 28 |
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:
- Do I use async? If no, requests is probably right.
- Do I need HTTP/2? If yes, HTTPX stays in contention.
- What’s my team comfortable with? Familiarity reduces bugs.
- Is migration worth the cost? Code changes, testing, team training.
- 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