NEWScrapingAnt MCP for Claude Code, Cursor & Windsurf — try it free →
Skip to main content

Requests vs HTTPX in 2026: Measured Differences and Migration Gotchas

· 26 min read
Oleg Kulyk
Co-Founder @ ScrapingAnt

Requests vs HTTPX in 2026: Measured Differences and Migration Gotchas

Updated 2026-09-17

Every timing on this page now comes from one local server run by the evidence packet on requests 2.34.2 and httpx 0.28.1. The earlier version's benchmark table, which quoted third-party posts and unsourced figures, is gone. New: connection counting, threads vs asyncio, HTTP/2 on the same server, what "retries" means in each library, the errors a migration hits, and what the httpx 1.0 pre-release does to 0.28 code.

requests and httpx make the same request with almost the same line of code. The differences that matter are elsewhere: whether a connection is reused, how you run 200 requests at once, which protocol you speak, what happens by default on a redirect or a slow server, and what "retry" means. This page measures each of those on one server, then lists what breaks when you move a requests codebase to httpx 0.28.

The short version, from the tables below (Apple Silicon laptop, loopback, Python 3.12.11; the ratios are the claim, not the seconds):

Caserequests 2.34.2httpx 0.28.1
300 sequential GETs, one Session / Client (plain HTTP)0.150 s, 1 connection0.126 s, 1 connection
300 sequential GETs, no Session / Client (plain HTTP)0.217 s, 300 connections3.090 s, 300 connections (an SSL context is built per call)
200 GETs with a 50 ms server delay, 20 threads0.543 s (32 connections; 20 with pool_maxsize=20)0.545 s, 20 connections
Same 200 GETs, AsyncClient + asyncio.gather, Semaphore(20)not available0.558 s, 20 connections
Same 200 GETs over TLS, no semaphore (httpx caps in-flight work at 100), HTTP/1.1 vs HTTP/2HTTP/1.1 only0.476 s, 200 connections vs 0.171 s, 1 connection
50 MiB download, streamed, peak RSS35 MiB44 MiB
Retries against a 503urllib3.Retry(status_forcelist=[503]): 4 requests sentHTTPTransport(retries=3): 1 request (retries cover connection failures only)

Pick requests when the code is synchronous, the targets speak HTTP/1.1, and you want the larger ecosystem (plugins, mocks, examples). Pick httpx when you are inside asyncio (or an async framework), when you want HTTP/2, or when you want timeouts on by default. On a single reused connection the two are within a third of each other, and the one case where requests came out ahead was the loopback streaming test; nothing here says one is "faster".

Test setup

Versions from the packet's first output file:

python: Python 3.12.11
requests 2.34.2
urllib3 2.8.0
httpx 0.28.1
httpcore 1.0.9
h2 4.4.1
anyio 4.15.1
hypercorn 0.18.0
OpenSSL 3.0.16 11 Feb 2025

The server is hypercorn running an ASGI app of under 100 lines (server.py in the packet): plain HTTP/1.1 on 127.0.0.1:8080, and TLS with ALPN (h2, http/1.1) on localhost:8443 with a self-signed certificate generated by run.sh. It serves /get, /post, /delay/<seconds>, /bytes/<n>, /status/<code> and /redirect/<n>, and it counts TCP connections and requests per path (/stats, reset before each run; the tables show the count from the last run next to the median time), so every table below can say how many connections a client actually opened. Timings are the median of 5 runs (3 for the slow ones). Every Python block on this page is a slice of a packet script; every output block is the captured file. To run it yourself (Python 3.12; needs openssl on PATH and outbound HTTPS for the three cases that fetch something):

python -m venv .venv && . .venv/bin/activate # Python 3.12
pip install -r requirements.txt
./run.sh

requirements.txt pins requests==2.34.2 and httpx==0.28.1 with the h2 extra; for your own project, pip install requests "httpx[http2]". The Python blocks below are slices of the packet scripts, so they assume these names and imports from the same files:

import asyncio
from concurrent.futures import ThreadPoolExecutor
import ssl
import httpx
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
HTTP = "http://127.0.0.1:8080"
HTTPS = "https://localhost:8443"
CERT = "certs/localhost.pem"
ctx = ssl.create_default_context(cafile=CERT)
N, WORKERS = 200, 20
URL = f"{HTTP}/delay/0.05"
CHUNK = 1024 * 1024

The same request in both

r1 = requests.get(f"{HTTP}/get", params={"q": "ant"})
r2 = httpx.get(f"{HTTP}/get", params={"q": "ant"})
--- GET
requests: 200 | application/json | 1.1 | str http://127.0.0.1:8080/get?q=ant
httpx: 200 | application/json | HTTP/1.1 | URL http://127.0.0.1:8080/get?q=ant
requests user-agent: python-requests/2.34.2
httpx user-agent: python-httpx/0.28.1
requests r.ok / httpx r.is_success: True True
requests r.is_success: AttributeError: 'Response' object has no attribute 'is_success'
httpx r.ok: AttributeError: 'Response' object has no attribute 'ok'
raise_for_status() returns: NoneType / Response
elapsed types: timedelta / timedelta

The visible differences on a plain GET: response.url is a str in requests and an httpx.URL object in httpx (str() it before comparing), r.ok is r.is_success in httpx, and httpx's raise_for_status() returns the response so it can be chained. The elapsed attribute is a timedelta in both, but httpx raises if you read it inside a stream() block before the body has been read (shown in the migration section).

Request bodies: form data and JSON behave the same, except that httpx serialises JSON compactly since 0.28 ({"k":"v"}, no spaces). Raw bytes go in content=; data=bytes still works but warns:

--- POST form, json, raw bytes
data=dict requests: 'application/x-www-form-urlencoded' 'k=v'
httpx: 'application/x-www-form-urlencoded' 'k=v'
json= requests: 'application/json' '{"k": "v"}'
httpx: 'application/json' '{"k":"v"}'
data=bytes requests: '' 'raw bytes'
DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content.
httpx: '' 'raw bytes' <- deprecated, use content=
content= httpx: '' 'raw bytes'

Two query-string details from the compatibility guide, checked on the wire: requests drops parameters whose value is None, httpx sends them empty; a list of tuples works in both (the guide says it is unsupported in httpx, but 0.28.1 sends k=1&k=2). And httpx's .get() refuses a body (the guide lists content, files, data and json; json= is the one run here); use .request("GET", ...) if a server insists on one:

--- query params edge cases
requests params={'a': None, 'b': 1}: b=1
httpx params={'a': None, 'b': 1}: a=&b=1
requests params=[('k','1'),('k','2')]: k=1&k=2
httpx params=[('k','1'),('k','2')]: k=1&k=2
httpx params={'k': ['1','2']}: k=1&k=2
--- request body on GET
requests.get(json=...): 200
httpx.get(json=...): TypeError: get() got an unexpected keyword argument 'json'

Two defaults that differ: redirects and timeouts

requests follows redirects; httpx does not unless you ask. requests waits forever; httpx gives up after 5 seconds of inactivity. Both defaults bite during a migration, in opposite directions.

--- redirects: GET /redirect/2 -> /redirect/1 -> /get
requests default: 200 history: [302, 302] final: http://127.0.0.1:8080/get
httpx default: 302 history: [] next_request: http://127.0.0.1:8080/redirect/1
httpx follow_redirects=True: 200 history: [302, 302] final: http://127.0.0.1:8080/get
requests allow_redirects=False: 302 next: http://127.0.0.1:8080/redirect/1
--- timeouts: GET /delay/6 (server sleeps 6 s)
requests default (no timeout): 200 after 6.0 s
httpx default (5 s): httpx.ReadTimeout: timed out
raised after 5.0 s
requests timeout=1: requests.exceptions.ReadTimeout: HTTPConnectionPool(host='127.0.0.1', port=8080): Read timed out. (read timeout=1)
httpx timeout=1: httpx.ReadTimeout: timed out
httpx timeout=None: 200
httpx.Timeout(10.0, connect=60.0): Timeout(connect=60.0, read=10.0, write=10.0, pool=10.0)
requests timeout=(connect, read) is a tuple: (3.05, 27)

The httpx default is Timeout(5.0) for connect, read, write and pool; timeout=None disables all four. requests takes a number or a (connect, read) tuple and has no pool timeout, because by default it never waits on its pool: HTTPAdapter ships with pool_block=False, so a thread that finds the pool busy opens another connection instead of queueing (the 32-connection row in the next section is that behaviour). If your scraper hits slow pages, the httpx default of 5 seconds is the first thing to raise; set timeout=httpx.Timeout(30.0, connect=10.0) on the client rather than per request.

Connection reuse: Session and Client

300 sequential GETs to /get, with and without a reusable object. The server's connection count is the column to read.

--- 300 sequential GET /get, plain HTTP (127.0.0.1:8080), median of 5 runs
requests.get() x N (no Session) median 0.217 s min 0.207 s conns opened 300 requests 300
requests.Session x N median 0.150 s min 0.150 s conns opened 1 requests 300
httpx.get() x N (no Client) median 3.090 s min 3.006 s conns opened 300 requests 300
httpx.get() x N (no Client, verify=ctx) median 0.186 s min 0.176 s conns opened 300 requests 300
httpx.get() x N (no Client, verify=False) median 0.237 s min 0.217 s conns opened 300 requests 300
httpx.Client x N median 0.126 s min 0.122 s conns opened 1 requests 300
--- 300 sequential GET /get, TLS (localhost:8443, self-signed, verify=certs/localhost.pem)
requests.get() x N (no Session) median 0.785 s min 0.686 s conns opened 300 requests 300
requests.Session x N median 0.181 s min 0.177 s conns opened 1 requests 300
httpx.get() x N (no Client, verify=CERT) median 0.678 s min 0.676 s conns opened 300 requests 300
httpx.get() x N (no Client, verify=ctx) median 0.621 s min 0.590 s conns opened 300 requests 300
httpx.Client x N median 0.136 s min 0.129 s conns opened 1 requests 300

Three things to take from this table:

  • Reuse the object. With a Session or a Client, 300 requests use one connection and the two libraries are close: 0.150 s vs 0.126 s plain (a 19% gap), 0.181 s vs 0.136 s over TLS (33%). Without one, every request opens a connection, and over TLS that is a handshake each time (4 to 5 times slower in both libraries).
  • httpx.get() without a client costs about 10 ms per call on this machine, even for an http:// URL. 300 calls took 3.09 s against 0.19 s with a prebuilt ssl.SSLContext and 0.24 s with verify=False. The reason is in the source: every HTTPTransport (and so every top-level httpx.get()) calls create_ssl_context() in its constructor, which loads the certifi CA bundle. urllib3 builds its context when an HTTPS connection is made, so requests pays nothing for plain HTTP. If you cannot use a Client, pass a context you built once. The fixed cost, measured on its own (03b_ssl_context.py, 50 constructions each):
ssl.create_default_context(cafile=certifi.where()) median 9.56 ms min 9.24 ms (n=50)
ssl.create_default_context(cafile=CERT) (one cert) median 0.18 ms min 0.17 ms (n=50)
httpx.HTTPTransport() (default verify=True) median 9.36 ms min 9.20 ms (n=50)
httpx.HTTPTransport(verify=ctx) median 0.00 ms min 0.00 ms (n=50)
httpx.HTTPTransport(verify=False) median 0.09 ms min 0.09 ms (n=50)
requests.Session() median 0.01 ms min 0.01 ms (n=50)
  • requests' Session is the same idea as httpx's Client. Both keep the connection pool, cookies and headers; httpx also keeps the SSL context. The httpx docs say the same: create one client and pass it around, never with httpx.Client() inside a hot loop.

Concurrency: threads vs asyncio

requests has no async API. The comparison that matters is "requests in a thread pool" against "httpx in a thread pool" against "httpx with asyncio". 200 GETs to /delay/0.05, where the server sleeps 50 ms per request, so the floor at concurrency 20 is 200 / 20 × 50 ms = 0.5 s.

def threads_requests_sized():
with requests.Session() as s, ThreadPoolExecutor(WORKERS) as pool:
s.mount("http://", HTTPAdapter(pool_maxsize=WORKERS))
list(pool.map(lambda _: s.get(URL), range(N)))
async def gather(limit, **kw):
sem = asyncio.Semaphore(limit)
async with httpx.AsyncClient(**kw) as c:
async def one():
async with sem:
return await c.get(URL)
await asyncio.gather(*(one() for _ in range(N)))
--- 200 x GET /delay/0.05, plain HTTP, concurrency 20 where applicable, median of 3 runs
requests.Session, sequential median 10.422 s min 10.410 s conns opened 1 requests 200
requests.Session + 20 threads median 0.543 s min 0.542 s conns opened 32 requests 200
urllib3 logged 'Connection pool is full, discarding connection: 127.0.0.1. Connection pool size: 10' 68 times over 3 runs
requests.Session + 20 threads, pool_maxsize=20 median 0.541 s min 0.540 s conns opened 20 requests 200
urllib3 'pool is full' warnings: 0
httpx.Client + 20 threads median 0.545 s min 0.538 s conns opened 20 requests 200
httpx.AsyncClient + gather, Semaphore(20) median 0.558 s min 0.557 s conns opened 20 requests 200
httpx.AsyncClient + gather, no semaphore median 0.367 s min 0.366 s conns opened 200 requests 200
httpx.AsyncClient(limits=Limits(20, 20)), no semaphore median 0.667 s min 0.665 s conns opened 20 requests 200
httpx default client limits: Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0)
requests/urllib3 default pool: pool_connections=10, pool_maxsize=10 (HTTPAdapter defaults)

At the same concurrency the three approaches finish within about 3% of each other (0.541 s, 0.545 s, 0.558 s): the server delay dominates, as it will on any real target. What differs:

  • A shared requests.Session in 20 threads opened 32 connections, not 20, and urllib3 logged "Connection pool is full, discarding connection" 68 times over three runs. The default HTTPAdapter keeps 10 connections per host; the other threads open new ones and throw them away. Mount an adapter with pool_maxsize equal to your thread count and the count drops to 20.
  • asyncio.gather with no semaphore opened 200 connections. httpx's default limits allow 100 connections at once and 20 kept alive; in httpcore 1.0.9 the pool closes a connection that goes idle whenever the pool holds more than max_keepalive_connections connections in total, so with 100 in flight every finished connection was closed and every queued request opened a new one (100 + 100). Setting limits=httpx.Limits(max_connections=20, max_keepalive_connections=20) caps the connections at 20 but was the slowest variant here (0.667 s), because tasks now queue on the pool; a semaphore around the request is cheaper.
  • Sequential is about 20 times slower than the concurrency-20 variants (10.4 s), which is the only comparison the old page's code ran.

Threads and asyncio give the same throughput for I/O-bound scraping at this scale. Pick asyncio when the rest of the program is async or when you want thousands of in-flight requests without thousands of threads; pick threads when the code around the request is synchronous.

HTTP/2

requests speaks HTTP/1.1 only: urllib3 2.x has an HTTP/2 mode that its changelog labelled experimental in 2.2.3 (September 2024) with no later entry lifting that, and requests does not enable it. httpx speaks HTTP/2 when you install the extra and ask for it.

with httpx.Client(verify=ctx, http2=True) as c:
r = c.get(f"{HTTPS}/get")
print("httpx.Client(http2=True): http_version =", r.http_version, "| server saw", r.json()["http_version"])
--- protocol negotiated on GET /get
requests: raw.version = 11 | server saw 1.1
httpx.Client(): http_version = HTTP/1.1
httpx.Client(http2=True): http_version = HTTP/2 | server saw 2
httpx.Client(http2=True) against the plain-HTTP port: HTTP/1.1
--- http2=True without the h2 package (subprocess with h2 blocked)
exit 1 | ImportError: Using http2=True, but the 'h2' package is not installed. Make sure to install httpx using `pip install httpx[http2]`.

Four facts from that block: http2=True is opt-in and off by default; it needs pip install httpx[http2] (the h2 package) or the client raises at construction; it is negotiated with ALPN over TLS, so against a plain http:// URL the same client falls back to HTTP/1.1; and response.http_version tells you what you got. Against the public GitHub Pages fixture the packet fetches, the answer was HTTP/2.

What HTTP/2 changes on this server:

HTTP/1.1 (http2=False) median 0.581 s min 0.568 s conns opened 20 requests 200
HTTP/2 (http2=True) median 0.573 s min 0.558 s conns opened 1 requests 200
--- the same 200 requests with no semaphore (httpx defaults cap in-flight work: 100 connections, or 100 streams on one HTTP/2 connection)
HTTP/1.1, no semaphore median 0.476 s min 0.471 s conns opened 200 requests 200
HTTP/2, no semaphore median 0.171 s min 0.169 s conns opened 1 requests 200
--- 300 sequential GET /get over TLS on one Client, median of 5 runs
httpx.Client() HTTP/1.1 median 0.108 s min 0.107 s conns opened 1 requests 300
httpx.Client(http2=True) median 0.125 s min 0.125 s conns opened 1 requests 300
  • At concurrency 20, HTTP/2 is no faster (0.573 s vs 0.581 s, inside run-to-run noise); it does the same work over 1 connection instead of 20.
  • With no semaphore, HTTP/2 finished in 0.171 s on one connection, HTTP/1.1 in 0.476 s after opening 200 TLS connections. In both rows httpx itself capped the work in flight at 100: max_connections=100 for HTTP/1.1, and httpcore's own limit of 100 concurrent streams for HTTP/2 (the server offered 200). Multiplexing pays when concurrency is high and connection setup is the cost.
  • Sequentially, HTTP/2 is slightly slower (0.125 s vs 0.108 s for 300 requests), most likely framing overhead with nothing to multiplex; the cause was not measured.

For scraping, the practical benefit is fewer connections to a target that supports HTTP/2, not faster pages. Whether a target supports it is a per-host fact; check response.http_version.

Streaming large bodies

A 50 MiB body, buffered and streamed, each case in its own process so the peak RSS is that case's alone.

def requests_streamed():
n = 0
with requests.get(URL, stream=True) as r:
for chunk in r.iter_content(CHUNK):
n += len(chunk)
return n
def httpx_streamed():
n = 0
with httpx.stream("GET", URL) as r:
for chunk in r.iter_bytes(CHUNK):
n += len(chunk)
return n
--- GET /bytes/52428800 (50 MiB), plain HTTP, one process per case, median of 3 runs
requests.get().content (buffered) bytes 52428800 median 0.038 s peak RSS 148 MiB
requests stream=True, iter_content(1 MiB) bytes 52428800 median 0.020 s peak RSS 35 MiB
httpx.get().content (buffered) bytes 52428800 median 0.051 s peak RSS 151 MiB
httpx.stream(), iter_bytes(1 MiB) bytes 52428800 median 0.044 s peak RSS 44 MiB

Streaming cuts peak memory from about 150 MiB to 35 MiB (requests) and 44 MiB (httpx) for a 50 MiB body (each row is the median-time run of three, with that run's RSS); both buffered variants hold the body twice at the moment .content is built (each joins a list of chunks into one bytes object). On loopback requests moved the bytes faster (0.020 s vs 0.044 s streamed); over a real network the transfer time swamps both. The API difference is shape, not capability: requests uses stream=True on the request and iter_content(), httpx uses a stream() context block and iter_bytes(); the block guarantees the response is closed when you leave it.

Errors and retries

The exception classes for the same five failures. Both libraries have one base class to catch (requests.exceptions.RequestException, httpx.HTTPError); httpx's hierarchy is deeper, so you can catch httpx.TransportError for transport-level failures (network, timeouts, unsupported scheme) and httpx.HTTPStatusError for "bad status" separately.

--- the same failures
404 + raise_for_status()
requests: requests.exceptions.HTTPError (bases: RequestException)
httpx: httpx.HTTPStatusError (bases: HTTPError)
connection refused (port 1)
requests: requests.exceptions.ConnectionError (bases: RequestException)
httpx: httpx.ConnectError (bases: NetworkError > TransportError > RequestError > HTTPError)
read timeout (/delay/2, timeout=0.5)
requests: requests.exceptions.ReadTimeout (bases: Timeout > RequestException)
httpx: httpx.ReadTimeout (bases: TimeoutException > TransportError > RequestError > HTTPError)
unknown host
requests: requests.exceptions.ConnectionError (bases: RequestException)
httpx: httpx.ConnectError (bases: NetworkError > TransportError > RequestError > HTTPError)
bad URL
requests: requests.exceptions.InvalidURL (bases: RequestException)
httpx: httpx.UnsupportedProtocol (bases: TransportError > RequestError > HTTPError)

"Retries" means different things. In requests, retries come from urllib3's Retry object mounted on an adapter, and it can retry on status codes. In httpx, HTTPTransport(retries=n) retries when establishing a connection fails (ConnectError, ConnectTimeout), and the docs point at tenacity for anything else; a 503 comes back as a 503, once. The server's request counter shows it:

s = requests.Session()
s.mount("http://", HTTPAdapter(max_retries=Retry(total=3, status_forcelist=[503], backoff_factor=0)))
--- retries against GET /status/503 (server counts requests)
requests + urllib3 Retry(total=3, status_forcelist=[503]): requests.exceptions.RetryError: HTTPConnectionPool(host='127.0.0.1', port=8080): Max retries exceeded with url: /status/503 (Caused by ResponseError('too many 503 error responses'))
server saw 4 requests
httpx.HTTPTransport(retries=3): 503
server saw 1 requests
--- retries against connection refused (port 1)
httpx.HTTPTransport(retries=3): httpx.ConnectError: [Errno 61] Connection refused
gave up after 1.5 s (backoff between attempts)
requests Retry(total=3, backoff_factor=0.5): requests.exceptions.ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=1): Max retries exceeded with url: / (Caused by NewConnectionError("HTTPConnection(host='127.0.0.1', port=1): Failed to establish a new connection: [Errno 61] Connection refused"))
gave up after 3.0 s

If you need "retry on 429 or 503 with backoff" in httpx, write the loop yourself or use tenacity; the transport will not do it. The previous version of this page claimed httpx "automatically retries failed requests"; it does not.

Migrating from requests to httpx 0.28

The errors and warnings a requests codebase hits on httpx 0.28.1, in the order you are likely to meet them:

--- arguments that do not exist or were removed
httpx.get(url, allow_redirects=True): TypeError: get() got an unexpected keyword argument 'allow_redirects'
httpx.Client(proxies={...}) (removed in 0.28): TypeError: Client.__init__() got an unexpected keyword argument 'proxies'
httpx.Client(proxy='http://127.0.0.1:3128'): Client
httpx.Client(mounts={'http://': httpx.HTTPTransport(proxy='http://127.0.0.1:3128')}): Client
requests.Session().proxies = {'http': ...}: ok
httpx.Client(app=...) (removed in 0.28): TypeError: Client.__init__() got an unexpected keyword argument 'app'
--- deprecated in 0.28 (still works, warns)
DeprecationWarning: `verify=<str>` is deprecated. Use `verify=ssl.create_default_context(cafile=...)` or `verify=ssl.create_default_context(capath=...)` instead.
httpx.get(url, verify='certs/localhost.pem'): 200
httpx.get(url, cert=(...)): TypeError: get() got an unexpected keyword argument 'cert'
DeprecationWarning: `cert=...` is deprecated. Use `verify=<ssl_context>` instead,with `.load_cert_chain()` to configure the certificate chain.
httpx.Client(verify=ctx, cert=(CERT, KEY)): Client
DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content.
httpx.post(url, data=b'raw'): 200
DeprecationWarning: Setting per-request cookies=<...> is being deprecated, because the expected behaviour on cookie persistence is ambiguous. Set cookies directly on the client instance instead.
client.post(url, cookies={...}) on a Client: 200
--- attributes with different names
requests r.reason / httpx r.reason_phrase: True / True
httpx r.reason: AttributeError: 'Response' object has no attribute 'reason'
requests r.raw / httpx r.raw: AttributeError: 'Response' object has no attribute 'raw'
httpx r.iter_content: AttributeError: 'Response' object has no attribute 'iter_content'
requests r.next / httpx r.next_request: None / None
str(httpx r.url) == requests r.url: True
requests.Session().mount / httpx.Client().mounts: True / dict
httpx elapsed inside stream block: RuntimeError: '.elapsed' may only be accessed after the response has been read or closed.
httpx elapsed after the block: 0:00:00.000571

As a table:

requestshttpx 0.28Notes
allow_redirects=True (default)follow_redirects=True (default False)TypeError otherwise
no timeouttimeout=5.0 on everythingtimeout=None restores requests' behaviour (not recommended)
proxies={"http": ..., "https": ...}proxy="http://host:port" or mounts={"http://": httpx.HTTPTransport(proxy=...)}proxies= removed in 0.28.0; scheme keys include ://
verify="/path/ca.pem"verify=ssl.create_default_context(cafile=...)string still works, warns
cert=(crt, key)load_cert_chain(...) on the context you pass as verify=cert= raised TypeError on httpx.get() in this run although the compatibility guide still lists it there; on Client it works and warns (captured above)
data=b"..."content=b"..."data= is for form dicts
session.get(url, cookies=...)httpx.Client(cookies=...)per-request cookies on a client warn
r.reasonr.reason_phrase
r.rawr.iter_raw() inside stream()no .raw attribute
r.iter_content()r.iter_bytes()inside stream()
r.nextr.next_request
r.url (str)str(r.url)httpx.URL object
session.mount(prefix, adapter)mounts={pattern: transport} at construction
r.okr.is_success
requests.exceptions.RequestExceptionhttpx.HTTPErrorbase classes

Everything in the httpx column is from the captured run, the compatibility guide or the SSL page (the load_cert_chain form); the removals are from the 0.28.0 changelog. For the requests side of TLS options, see requests-ignore-ssl; for proxies, python-requests-proxy; for headers in httpx, change-user-agent-httpx.

Where the two projects stand (September 2026)

requests 2.34.2 was released on 2026-05-14 and requires Python 3.10 or newer. The 2.34.0 release (2026-05-11) added inline type hints, replacing the typeshed stubs, and added Python 3.14t and 3.15 beta support.

httpx 0.28.1, the stable release used here, is from 2024-12-06 and still accepts Python 3.8. The 1.0 line exists as pre-releases on PyPI (1.0.dev6, 2026-08-31) and as a design proposal describing "a complete HTTP toolkit for Python", client and server, with a separate ahttpx package for async; the page says the repository is currently private and the design work is not yet licensed for reuse. The packet installs 1.0.dev6 into a scratch directory and runs the 0.28 calls from this page against it:

httpx 1.0.dev6 from generated/httpx-1.0.dev6/httpx
httpx.get(URL).status_code: AttributeError: module 'httpx' has no attribute 'get'
httpx.get(URL).json()['http_version']: AttributeError: module 'httpx' has no attribute 'get'
httpx.Client(): Client
with httpx.Client() as c: c.get(URL): 200 OK
httpx.AsyncClient: AttributeError: module 'httpx' has no attribute 'AsyncClient'
httpx.Client(http2=True): TypeError: Client.__init__() got an unexpected keyword argument 'http2'
httpx.get(URL, follow_redirects=True): AttributeError: module 'httpx' has no attribute 'get'
httpx.get(URL, timeout=5): AttributeError: module 'httpx' has no attribute 'get'
httpx.get('http://127.0.0.1:8080/redirect/1') default: AttributeError: module 'httpx' has no attribute 'get'
httpx.post(URL.replace('get','post'), json={'k': 'v'}): AttributeError: module 'httpx' has no attribute 'post'
httpx.post(..., data={'k': 'v'}): AttributeError: module 'httpx' has no attribute 'post'
httpx.stream('GET', URL): AttributeError: module 'httpx' has no attribute 'stream'
httpx.Timeout: AttributeError: module 'httpx' has no attribute 'Timeout'
httpx.HTTPTransport: AttributeError: module 'httpx' has no attribute 'HTTPTransport'
httpx.Response(200, content=b'x').status_code: 200 OK
hasattr(httpx, 'run') (server): False
public names (31): Binary, ByteStream, Client, Connection, ConnectionPool, Content, DuplexStream, Empty, File, FileStream, Files, Form, HTML, HTTPParser, Headers, JSON, Method, Mode, MultiPart, MultiPartStream, NetworkBackend, NetworkStream, ProtocolError, QueryParams, Request, Response, Server, Stream, Text, Transport, URL

In 1.0.dev6, Client().get() works and returns 200 OK, but the top-level httpx.get()/post()/stream(), AsyncClient, http2=, Timeout and HTTPTransport from this page do not exist. Treat 1.0 as a different library that is not finished, pin httpx<1 in production, and do not plan a migration around the pre-release until it stabilises.

Popularity, for whoever asks "is httpx mainstream yet": 30-day PyPI downloads from the hugovk top-pypi-packages snapshot dated 2026-09-01, fetched 2026-09-17:

requests rank 7 downloads 1,695,910,251
httpx rank 33 downloads 818,096,438
aiohttp rank 49 downloads 624,758,174

requests is downloaded about twice as often as httpx; httpx is ahead of aiohttp, another async client. All three are in the top 50 of 15,000 packages, so ecosystem risk is not a reason to pick either.

Which one to pick

  • Synchronous code, HTTP/1.1 targets, a team that knows requests: stay on requests 2.34 with a Session, an adapter with pool_maxsize matching your thread count, an explicit timeout, and Retry(status_forcelist=...) if you want status retries.
  • Async code, or an async framework: httpx AsyncClient, one instance for the program, a semaphore around the request, timeout set to what your targets need.
  • HTTP/2, either to reduce connections to a target or because the target prefers it: httpx with httpx[http2] and http2=True; verify with response.http_version.
  • Streaming or large downloads: either; the memory profile is the same shape.
  • Raw speed on one connection: not a reason to choose; 0.150 s vs 0.126 s per 300 requests here, and requests was ahead in the streaming test.
  • aiohttp, urllib3 or others in the picture: see best Python HTTP clients; this page measures the two most downloaded.

When ScrapingAnt is not needed, and when it is

Everything on this page is about targets that answer a plain HTTP request. If your targets do, neither library needs help, and this comparison is the whole decision.

When a target needs JavaScript rendering or a residential IP, the request goes to the ScrapingAnt API with either library; the client code is the same shape as any other GET. When you call the ScrapingAnt API, your code makes one TLS connection, to api.scrapingant.com; the target page is fetched by ScrapingAnt's infrastructure, so the target's certificate is not something your client verifies. Setting browser=false performs the request without a headless browser (no JavaScript rendering); the docs describe it for static content, and such a request costs 1 API credit through a datacenter proxy. Each response from the web scraping API contains the Ant-credits-cost header with the number of credits spent on the request. The packet's last script makes the call with both clients (TARGET, API and KEY are defined at the top of 11_scrapingant.py); it runs only when SCRAPINGANT_API_KEY is set, and the recorded run is the skipped case, so no API output is quoted here:

params = {"url": TARGET, "browser": "false"}
r = requests.get(API, params=params, headers={"x-api-key": KEY}, timeout=60)
print("requests:", r.status_code, "Ant-credits-cost:", r.headers.get("Ant-credits-cost"), "bytes:", len(r.content))
with httpx.Client(timeout=60) as c:
r = c.get(API, params=params, headers={"x-api-key": KEY})
$ python 11_scrapingant.py
skipped: SCRAPINGANT_API_KEY is not set
exit=0

Request and response format: https://docs.scrapingant.com/request-response-format.

Limitations

  • All timings are from one Apple Silicon laptop over loopback; there is no network latency, so connection setup and TLS handshakes weigh more than they would against a remote host, and the sequential rows would move closer together. The packet re-runs monthly in CI on Ubuntu; the ratios and connection counts are what this page claims.
  • The server is a single hypercorn process; a different server (nginx, a CDN) changes the HTTP/2 numbers.
  • httpx 1.0 was tested as the 1.0.dev6 pre-release only; anything about it can change.
  • Proxy behaviour is shown at construction time only (no proxy was run); 11_scrapingant.py was skipped in the recorded run because no API key was set.

Examples tested on 2026-09-17 with Python 3.12.11, requests 2.34.2, urllib3 2.8.0, httpx 0.28.1, httpcore 1.0.9, h2 4.4.1, hypercorn 0.18.0. Code: https://github.com/ScrapingAnt/scrapingant-examples/tree/main/examples/requests-vs-httpx.

This article was drafted with AI assistance from a tested evidence packet and reviewed by the named author, who is responsible for the code, measurements and corrections.

Forget about getting blocked while scraping the Web

Try out ScrapingAnt Web Scraping API with thousands of proxy servers and an entire headless Chrome cluster