httpx compares to requests and when to use each;Learn Python Series):Welcome back to the Learn Python Series! It's been a while since episodes #13-17, where we built a web crawler using the requests library and fetched data from the CoinMarketCap API. If you haven't read those episodes, I encourage you to check them out - the fundamentals we covered there still apply today.
But it's now 2026, and the Python ecosystem has evolved. In this episode, we'll focus on what's new and what's changed - not rehashing the basics, but building on them.
Back in 2018, we learned:
requests library (#13)Those fundamentals haven't changed. What HAS changed is the tooling and best practices around them.
While requests is still perfectly valid, httpx has emerged as a modern alternative that offers both sync AND async support:
pip install httpx
import httpx
# Synchronous (just like requests)
response = httpx.get("https://api.coingecko.com/api/v3/ping")
print(response.json())
# The API is nearly identical to requests
response = httpx.get(
"https://api.example.com/data",
params={"key": "value"},
headers={"Accept": "application/json"},
timeout=10.0
)
When to use which:
requests: Battle-tested, huge ecosystem, synchronous onlyhttpx: Modern, async support built-in, HTTP/2 supportNota bene: If you're doing async programming (which we'll cover in episodes #39-40), httpx is the clear choice. For simple synchronous scripts, either works fine.
In 2018, we mostly dealt with simple API keys in URLs. Today's APIs often use more sophisticated auth:
import httpx
token = "your_jwt_token_here"
response = httpx.get(
"https://api.example.com/protected",
headers={"Authorization": f"Bearer {token}"}
)
import httpx
# OAuth2 client credentials flow
token_response = httpx.post(
"https://auth.example.com/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": "your_client_id",
"client_secret": "your_client_secret"
}
)
access_token = token_response.json()["access_token"]
# Use the token
client = httpx.Client(
headers={"Authorization": f"Bearer {access_token}"}
)
response = client.get("https://api.example.com/data")
Never hardcode credentials. This was true in 2018, but I didn't emphasize it enough. Here's the proper way:
import os
from dotenv import load_dotenv
# Load from .env file
load_dotenv()
API_KEY = os.getenv("API_KEY")
API_SECRET = os.getenv("API_SECRET")
if not API_KEY:
raise ValueError("API_KEY environment variable not set")
Your .env file (never commit this!):
API_KEY=your_actual_key_here
API_SECRET=your_actual_secret_here
Your .gitignore:
.env
In #13-17, our error handling was basic. Here's the modern approach:
import httpx
from httpx import HTTPStatusError, RequestError, TimeoutException
def fetch_with_resilience(url: str, max_retries: int = 3) -> dict:
"""Fetch data with proper error handling and retries."""
for attempt in range(max_retries):
try:
with httpx.Client(timeout=10.0) as client:
response = client.get(url)
response.raise_for_status()
return response.json()
except TimeoutException:
print(f"Timeout on attempt {attempt + 1}")
if attempt == max_retries - 1:
raise
except HTTPStatusError as e:
if e.response.status_code == 429: # Rate limited
retry_after = int(e.response.headers.get("Retry-After", 60))
print(f"Rate limited. Waiting {retry_after}s...")
import time
time.sleep(retry_after)
elif e.response.status_code >= 500:
print(f"Server error {e.response.status_code}, retrying...")
else:
raise # Client errors (4xx) shouldn't be retried
except RequestError as e:
print(f"Network error: {e}")
if attempt == max_retries - 1:
raise
raise Exception("Max retries exceeded")
In episode #36, we'll dive deep into type hints. But here's a preview of how they improve API code:
from dataclasses import dataclass
from typing import Optional
import httpx
@dataclass
class CryptoPrice:
symbol: str
price_usd: float
change_24h: float
market_cap: Optional[float] = None
def get_crypto_price(coin_id: str) -> CryptoPrice:
"""Fetch and return typed cryptocurrency data."""
response = httpx.get(
"https://api.coingecko.com/api/v3/simple/price",
params={
"ids": coin_id,
"vs_currencies": "usd",
"include_24hr_change": "true",
"include_market_cap": "true"
}
)
response.raise_for_status()
data = response.json()[coin_id]
return CryptoPrice(
symbol=coin_id.upper(),
price_usd=data["usd"],
change_24h=data.get("usd_24h_change", 0),
market_cap=data.get("usd_market_cap")
)
# Usage - IDE now knows exactly what fields are available
price = get_crypto_price("bitcoin")
print(f"{price.symbol}: ${price.price_usd:,.2f} ({price.change_24h:+.2f}%)")
One thing requests doesn't support but httpx does:
import httpx
# HTTP/2 is automatic when the server supports it
with httpx.Client(http2=True) as client:
response = client.get("https://www.google.com")
print(f"HTTP Version: {response.http_version}") # HTTP/2
2018 (from episode #15):
import requests
import json
response = requests.get("https://api.coinmarketcap.com/v1/ticker/bitcoin/")
data = response.json()
print(data[0]["price_usd"])
2026 (modern approach):
import httpx
import os
from dataclasses import dataclass
@dataclass
class BitcoinPrice:
usd: float
change_24h: float
def get_bitcoin_price() -> BitcoinPrice:
with httpx.Client(timeout=10.0) as client:
response = client.get(
"https://api.coingecko.com/api/v3/simple/price",
params={"ids": "bitcoin", "vs_currencies": "usd", "include_24hr_change": "true"}
)
response.raise_for_status()
data = response.json()["bitcoin"]
return BitcoinPrice(usd=data["usd"], change_24h=data["usd_24h_change"])
price = get_bitcoin_price()
print(f"${price.usd:,.2f} ({price.change_24h:+.2f}%)")
The core concept is the same, but the 2026 version has:
In this refresher episode, we covered:
httpx as a modern alternative to requestsIn the next episode, we'll explore advanced API patterns including POST/PUT/DELETE requests, handling pagination, and rate limiting strategies.
Thank you for your time! It's great to be back writing the Learn Python Series after all these years. If you have any questions, feel free to ask in the comment section below!