1.067.1 Python HTTP Clients#

Python HTTP clients compared and measured: requests, httpx, aiohttp, urllib3, niquests, curl-cffi. Why reusing the connection beats choosing the library, and the 28x cost of httpx.get().

At a glance#

LibraryHow it worksBest forLatest release
requestsSynchronous client over urllib3; sessions, auth, redirects, cookiesScripts, CLIs, and any synchronous code2.34.2 · 2026-05-14
httpxSync and async from one API, over httpcore and h11; HTTP/2 optionalCode that is genuinely both sync and async — with its async half measured first0.28.1 · 2026-08-31
aiohttp (client)asyncio client and server in one library, own transport, optional C parserAsync services, and especially ones that also serve HTTP3.14.3 · 2026-07-23
urllib3The connection layer under requests, and a usable client aloneLibraries other people install, and anywhere retries need configuring2.7.0 · 2026-05-07
niquestsDrop-in requests fork with HTTP/2, HTTP/3 and async, over a urllib3 forkAn existing requests codebase that needs async without touching every call site3.21.1 · 2026-08-28
curl-cffiBindings to curl-impersonate; reproduces browser TLS fingerprintsRequests that are being refused, which is a capability question not a speed one0.16.3 · 2026-09-02
primpThe same idea with a Rust core, over rquestWatching; too early to depend on2.0.0 · 2026-08-26
pycurlBindings to libcurlProtocol corners nothing Python-native reaches7.47.0 · 2026-06-29
treqA requests-shaped API over Twisted’s HTTP clientCode already inside a Twisted reactor26.7.0 · 2026-07-02
httpcore and h11httpx’s connection layer, and its HTTP/1.1 protocol implementationNothing — they are layers, not choices1.0.9 · 2025-04-24
The standard libraryurllib.request and http.clientA package advertising zero dependencies1.0.1 · 2025-12-10

Latest release observed from npm and PyPI in 2026-09.

What the research found

  • httpx.get() costs 28x its own session form, because httpx builds an SSL context eagerly — 22,021 us against 783. Every other client’s casual-call penalty is 2-2.5x, so it was isolated rather than reported as an oddity: httpx.Client() construction alone is 18,819 us and ssl.create_default_context() is 18,111 us of it, while requests.Session() costs 24.7 us because it defers the context until an HTTPS request needs one. httpx.get() therefore loads the system certificate bundle on every call, including against a plain http:// URL that will never use TLS. A thousand-iteration loop spends eighteen seconds loading the same certificates. Both libraries have the same footgun; in one it is three orders of magnitude deeper.
  • Reusing the connection matters more than which library you choose — The whole field spans 1.8x per request. Skipping the session costs 2.0x to 28.1x. And those are FLOORS: a TCP handshake is microseconds on localhost and a network round trip in production, with a full TLS negotiation on top, so the defect is worse in deployment than this survey can measure. Across six S3 situations the per-request ranking changed no recommendation at all; the session gap decided one outright.
  • aiohttp scales 10x with concurrency and httpx’s async client does not scale at all — aiohttp 1,475 -> 15,185 rps from concurrency 1 to 32; httpx async 824 -> 719, slower at 128 than at 1. A 21x gap at c=32 between two libraries doing the same job against the same server on the same loop. The obvious confound was tested and ruled out — httpx’s max_keepalive_connections defaults to 20 independent of max_connections, and raising it to match changed the figure by 0.9x and 1.0x. THE CAUSE WAS NOT ISOLATED, and the survey says so rather than offering an explanation the measurement does not support.
  • Threads are not a substitute for an async client — Every synchronous client plateaus between 750 and 1,950 rps however many threads are pointed at it; urllib3 peaks at 1,947 and requests never exceeds 912. aiohttp at concurrency 32 does 15,185 — roughly eight times the best synchronous result. This is the client-side half of 1.241’s L4, where a synchronous handler inside an async framework was capped by a 40-slot thread pool. A service doing both pays both.
  • The library recommended as the modern replacement is the least actively maintained one here — httpx’s stable release is 2024-12-06 and its last commit 2026-02-23, with httpcore stopping 2025-10-13 and h11 2025-04-24 — three layers, all quiet, with a 1.0 sitting in pre-release. requests committed three days before measurement and closes 96.5% of its issues; aiohttp and niquests both committed the day of measurement. This does not make httpx a bad library — it is faster per request than requests — it makes the common advice one release cycle out of date.

us per request connection reused

  • curl cffi: 587
  • urllib3: 756
  • httpx: 783
  • httpx http2: 786
  • stdlib urlopen: 956
  • niquests: 976
  • requests: 1044

us per request no session

  • stdlib urlopen: 1026
  • urllib3: 1884
  • requests: 2123
  • niquests: 2124
  • httpx: 22021

cost of skipping the session

  • stdlib: 1.1x
  • requests: 2.0x
  • niquests: 2.2x
  • urllib3: 2.5x
  • httpx: 28.1x

httpx 28x cause us

  • httpx Client construction: 18819
  • ssl create default context: 18111
  • requests Session construction: 24.7
  • niquests Session construction: 52.7

async rps

  • aiohttp: c1 1475, c8 10405, c32 15185, c128 9130
  • niquests: c1 848, c8 2859, c32 2857, c128 2619
  • httpx: c1 824, c8 983, c32 719, c128 609

sync on threads rps peak

  • urllib3: 1947
  • niquests: 1256
  • httpx: 1209
  • requests: 912

Explainer

Domain Explainer: Python HTTP Clients#

What these libraries do, the one habit that matters more than the choice between them, and why the layers underneath are where most of the behavior lives. Terms are defined where they first appear.


The job#

Your program needs something from another computer over the internet — a price, a record, a file. It opens a connection to that machine, sends a short text message saying what it wants, and reads the reply.

An HTTP client is the library that does all of that so your code can write one line.

This is the mirror image of the two neighboring surveys. 1.241 is about the framework that receives a request; 1.242 is about the server that hands that request over. This is what you use when your program is the one asking.

The three layers, and why they matter#

Every request goes through three things, and they are usually three different projects:

what it doesexamples
the clientthe API your code callsrequests, httpx
the connection layeropening, reusing and pooling connections; retries; encryptionurllib3, httpcore
the protocolturning a message into bytes and backh11, h2

Most people choose the top one and most of the behavior comes from the middle one. That is why “requests is doing something odd” is usually urllib3 doing something odd, and why the retry settings you eventually need belong to a layer you did not pick.

The habit that matters more than the choice#

Opening a connection is expensive, and reusing it is free.

Getting a connection to another machine takes a conversation of its own — a handshake, and over an encrypted link, a longer one. That cost is paid once per connection, not once per request. If you keep the connection open, the second request skips all of it.

Every library here does this correctly when you ask it to, and none of them do it when you use the convenient one-line form:

requests.get(url)        # opens a connection, uses it once, throws it away
session.get(url)         # reuses the one it already has

Measured on one machine, where the handshake is as cheap as it ever gets: the convenient form costs about twice as much for most libraries. On a real network it is worse, because the handshake becomes a round trip across the internet rather than a hop between two processes.

For one request, none of this matters. For a service making thousands, it is the largest difference in the survey — larger than the gap between the fastest and slowest library.

The one that is much worse#

One library, httpx, costs twenty-eight times more through the convenient form than through the reusable one. The reason is specific and slightly absurd: building an httpx client loads the computer’s entire list of trusted certificate authorities, which takes about eighteen milliseconds, and it does this even when the address does not use encryption. requests waits until it actually needs that list.

So both libraries have the same trap, and in one of them it is a thousand times deeper.

Waiting, and why there are two kinds of library#

Most of what a client does is wait for the other machine to answer. Two ways to arrange a program around that, and it decides which libraries you can use:

  • Synchronous: your program stops until the answer arrives. Simple, and one request at a time.
  • Asynchronous: your program sets the request aside and does something else, coming back when the answer lands. Hundreds can be in the air at once.

requests is synchronous only, and will always be — that is a decision its authors have made and restated. aiohttp is asynchronous only. httpx and niquests do both.

This is not a preference. Survey 1.241 established that using a synchronous client inside an asynchronous service stops the whole service while the request is out — not just that one request. It is a correctness problem wearing a performance problem’s clothes.

Measured here: an asynchronous client handling many requests at once reached fifteen thousand a second; every synchronous one, given as many threads as you like, stopped below two thousand. Threads are a way to stop a synchronous client blocking. They are not a way to make it fast.

Two things worth knowing before you need them#

No timeout by default. requests.get(url) against a machine that never answers will wait forever. There is no default limit, and every call needs one passed in. httpx waits five seconds. This is the most common way a working service quietly stops working.

Encryption has a fingerprint. The way a program negotiates an encrypted connection is recognizably different between a browser and a Python script, before any of the message is read. Sites that want to block automated traffic use this, and no amount of pretending to be a browser in the message itself helps. Two libraries exist to imitate a real browser’s handshake, and they are the only thing that works when that is the problem.

What to take away#

  1. Reuse the connection. Worth more than which library you pick, and much more on a real network than the measurements here can show.
  2. Match the library to how your program waits. A synchronous client inside an asynchronous service is a correctness bug.
  3. Always pass a timeout.
  4. The library you choose is not where most of the behavior lives. Retries, pooling and encryption belong to a layer underneath, and that layer is worth knowing by name.
  5. Speed between libraries is the smallest thing on this list. The whole field spans less than a factor of two per request, and every item above it is larger.
S1: Rapid Discovery

aiohttp (client)#

What it is#

An asyncio HTTP client and server in one library. Survey 1.241 judged the server half and recorded that the client half belonged here; this is that half.

It is the oldest async HTTP client in Python still in wide use, predating both asyncio’s maturity and httpx entirely.

Concurrency model and stack#

Async only. aiohttp.ClientSession is the unit, and unlike requests there is no module-level convenience function that quietly skips the session — the API pushes you toward reuse, which is the right default and one fewer footgun than requests has.

Its transport is its own. There is no urllib3 or httpcore underneath; connection pooling, the HTTP parser and the TLS handling are aiohttp’s, with an optional C parser (llhttp, the same one uvicorn uses through httptools).

It ships platform wheels, being partly C — the only mainstream client in this survey that does. That is a packaging consideration httpx and requests do not have.

The relationship with the server half#

The two halves share connection handling and streaming primitives, and 1.241’s persona 4 — a service whose real work is calling other services — is the case where using both is a genuine architectural advantage rather than an accident of what was already installed.

Nothing forces that. Using aiohttp purely as a client is normal and common, and it is how most of its installs are used.

Measured position#

16,500 stars. Version 3.14.3, released 2026-07-23. Platform wheels, Python ≥3.10. License is the compound Apache-2.0 AND MIT, which 1.241 also flagged as the only such expression in its survey.

Its download figure is among the largest in Python, and it does not separate client use from server use — nothing in the registry does, so no conclusion about the client’s popularity follows from it.

Trade-offs#

You get a mature async client with thirteen years of production hours, a session-first API, strong streaming support, an optional C parser, and one library if you also serve HTTP.

You give up any synchronous path — there is no aiohttp equivalent of requests.get for a script — and you give up the pure-Python install. You give up API familiarity: it does not look like requests, which httpx does on purpose.

Where it is weak: as a general-purpose client for code that is sometimes synchronous. It is an asyncio library through and through, and that is a commitment rather than a feature.


Also in the category#

Four more, plus the standard library. Two are dependencies rather than choices, one is a Twisted-shaped answer, and one is what everything else is measured against.


pycurl#

What it is. Bindings to libcurl — the same engine behind curl(1), decades of protocol handling, and support for more transfer protocols than any Python-native client.

Why it matters. Where a request needs something obscure and correct — an unusual proxy arrangement, a protocol nothing else speaks, precise low-level control — libcurl has probably handled it since before the Python alternatives existed.

Measured. 1,168 stars, 5.25 million downloads a month, version 7.47.0 released 2026-06-29. Platform wheels and sdist, Python ≥3.10.

The trade. A C API surfaced almost unchanged into Python: option constants, callbacks and manual buffer handling rather than r.json(). It is not pleasant and it is not trying to be.


treq#

What it is. A requests-shaped API over Twisted’s HTTP client, for code already living in a Twisted reactor.

Measured. 604 stars, 214,000 downloads a month — the smallest here — version 26.7.0 released 2026-07-02, pure wheel.

The trade. It only makes sense inside Twisted, and Twisted is a smaller world than it was. 1.242 found the same shape one layer over: daphne runs on Twisted and is the slowest ASGI server measured. This is the client-side equivalent — a competent library whose constituency is defined by a framework choice made years ago.


httpcore and h11 — layers, not choices#

httpcore is httpx’s connection layer and h11 is its HTTP/1.1 protocol implementation. Neither is something application code calls.

They are here because httpx’s release state cannot be read without them: httpcore’s stable is 1.0.9 from 2025-04-24 and h11’s is 0.16.0 from the same day, sixteen months before this measurement. A survey that reports only httpx’s own version describes a third of the stack.

httpcore draws 838 million downloads a month — more than any server or framework in surveys 1.241 and 1.242 — entirely as a dependency. Nobody chooses it and almost every modern Python HTTP stack contains it.

h2, the HTTP/2 implementation, is the same kind of thing at 207 million a month, and it is what httpx[http2] installs.


The standard library#

urllib.request and http.client are in every Python install and are the floor everything here is measured against.

They work. They are also verbose enough that requests became one of the most-starred Python packages largely by wrapping them, and urllib.request’s defaults — no connection reuse, no retry policy, awkward error handling — are why nobody uses it twice.

Worth knowing for one case: a library that must have zero dependencies can make HTTP requests with the standard library alone, and that is a real constraint for packages meant to be installed everywhere. Everything else here is a dependency somebody’s consumer will inherit.


What is compared here#

Twelve libraries that make HTTP requests from Python, plus the transport layers underneath the two that have them. Each file states the same things: what it is, its concurrency model, what it is built on, how it is packaged, its measured position, and what you give up.

What counts as an HTTP client#

A library your code calls to make a request to somebody else’s server. That is the line against every neighbouring survey:

  • 1.241 is the framework that receives a request.
  • 1.242 is the server that hands the request to the framework.
  • This is what you use when your code is the one asking.

Three things are in scope and worth naming, because they are usually conflated:

  1. The client APIrequests, httpx, niquests. What application code calls.
  2. The connection layerurllib3, httpcore. Pooling, retries, TLS, keep-alive. Usually a dependency rather than a choice, and the place a surprising amount lives.
  3. The protocol implementationh11, h2. Bytes to messages, no I/O at all.

Most decisions are made at layer 1 and most behavior comes from layer 2, which is why the stack is drawn out per library rather than left implied.

Boundary cases, decided#

  • aiohttp is a client and a server. 1.241 judged its server half and said the client half belonged here. In, judged as a client only.
  • urllib3 is requests’ transport and also a perfectly usable client on its own. In, both ways, because “just use urllib3” is real advice with real consequences.
  • httpcore and h11 are not user-facing and are included as layers rather than as candidates, because httpx’s release state cannot be read without them.
  • curl-cffi and primp exist to impersonate browser TLS fingerprints. In: it is a distinct job nothing else here does, and the reason someone reaches for them is not performance.

Out of scope#

  • Stdlib urllib.request and http.client are named where they matter and are not given files. They are the floor everything else is measured against, not a choice anyone makes twice.
  • Scraping frameworks — Scrapy, and anything that owns the crawl loop rather than the request.
  • API SDKs that wrap one vendor’s endpoints.
  • Retry and caching wrapperstenacity, requests-cache. They sit beside a client rather than replacing one.

What a client actually decides#

Four axes, and every file is written against them:

  • Sync, async, or both. The deepest split, and the one that follows from 1.241: a service using an async framework needs a client that does not block its event loop.
  • Connection reuse. Whether the library pools connections by default, and what happens when you use the module-level convenience functions instead of a session.
  • What it is built on. A pure-Python stack, libcurl, or a Rust core — which decides both packaging and what happens when something goes wrong beneath the API.
  • HTTP/2 and beyond. Optional in some, absent in most.

curl-cffi and primp#

Two libraries that exist for a job nothing else here does: making a Python request look like a browser’s.

What they are for#

A TLS handshake has a fingerprint — cipher order, extensions, ALPN, HTTP/2 settings frames — and it differs between Chrome, Firefox and every Python client. Anti-bot services read that fingerprint, so a request from requests or httpx is identifiable as automated before a single header is examined, regardless of the User-Agent it sets.

curl-cffi and primp impersonate real browser fingerprints. That is the entire proposition, and it is a capability question rather than a performance one: either the target accepts the request or it does not.

curl-cffi#

Bindings to curl-impersonate, a patched libcurl that reproduces specific browser TLS signatures, with a requests-shaped API on top.

6,405 stars, version 0.16.2 released 2026-08-25, platform wheels, Python ≥3.10. Both synchronous and asynchronous interfaces, and HTTP/2 comes free from libcurl.

It is the most-adopted library in this survey that is not a general-purpose client, and by a wide margin the usual answer when a target blocks ordinary Python clients.

primp#

A Rust implementation of the same idea, built on rquest. 578 stars, version 2.0.0 released 2026-08-26, platform wheels, Python ≥3.10.

Newer, smaller, and reaching 2.x quickly. It is the same wager granian makes one survey over — a Rust core under a Python API — and its adoption relative to curl-cffi is roughly what granian’s was to uvicorn two years ago.

What both cost#

Platform wheels, so a wheel must exist for the target. A binary you did not build, which is a supply-chain consideration a pure-Python client does not raise. And a moving target: browser fingerprints change with browser releases, so impersonation is maintenance in a way ordinary HTTP is not.

The line worth drawing#

Neither is a better requests. Reaching for one because it is fast, or because it is newer, is reaching past a general-purpose client for a specialist tool with a narrower support surface.

They are the right answer when the request is being refused, and the wrong answer when it is merely slow.


httpx#

What it is#

A client with requests’ API shape and both a synchronous and an asynchronous interface, plus HTTP/2 as an option. It is the answer the ecosystem gives when an async service needs to make outbound calls, and it is what FastAPI’s own test client is built on.

Concurrency model and stack#

Both, from one API. httpx.Client is synchronous, httpx.AsyncClient is asynchronous, and the two present the same methods. Nothing else in this category offers that symmetry — requests is sync only and aiohttp is async only.

The stack is three projects deep, and reading it is necessary to read httpx’s state:

httpx        the API — sessions, auth, redirects, the requests-shaped surface
  httpcore   the connection layer — pooling, proxies, HTTP/1.1 and HTTP/2 plumbing
    h11      the HTTP/1.1 protocol implementation, no I/O at all
    h2       HTTP/2, when installed

All three are pure Python and all three are Encode projects.

Measured position, and the number that needs reading carefully#

Stable release 0.28.1, uploaded 2024-12-06 — twenty-one months before this measurement. 15,400 stars. Pure wheel, Python ≥3.8.

PyPI’s newest file for httpx is dated 2026-08-21, and it is 1.0.dev5, a pre-release. Reading the newest file as the current release makes a 21-month-old stable look like it shipped last week; the stable version is what pip install httpx gives you, and that is December 2024.

The layers underneath are quieter still:

stableuploadedrepo last pushed
httpx0.28.12024-12-062026-03-29
httpcore1.0.92025-04-242026-01-08
h110.16.02025-04-24

Last commit on httpx’s default branch: 2026-02-23, and 143 open pull requests.

That figure needs its own sentence, because the obvious reading is wrong. httpx has GitHub issues disabled (has_issues: false) and uses Discussions instead, so the 143 the API reports are entirely PRs. It is not a bug backlog — it is contributions arriving and not being merged, which for a project whose last commit is February is the sharper signal of the two.

Encode is not moving at one speed#

The comparison that makes this legible is with Encode’s other projects. Starlette and uvicorn were transferred out of Encode to Kludex (surveys 1.241 and 1.242) and both shipped within the last week. httpx and httpcore stayed at Encode, and neither has had a stable release in over a year.

Whatever that reflects, the practical position is the same: the most-recommended modern Python HTTP client has a 21-month-old stable release and a 1.0 that has been in pre-release for months. S4 takes up what that means; S1 records it because no adoption figure shows it.

Trade-offs#

You get one API for sync and async, HTTP/2 when you ask for it, a requests-shaped surface that transfers, strict timeouts by default where requests has none, and a design that corrects several of its predecessor’s defaults.

You give up release velocity, and you take on a three-project stack whose middle layer is quieter than the top. You give up 1.0 — it has been 0.x throughout, and the 1.0 that would end that is unreleased.

Where it is weak: nowhere technically that this pass can find. The concern is entirely about the project’s rate of change, which is S4’s question rather than S1’s.


niquests#

What it is#

A drop-in fork of requests that adds what requests has declined to add: HTTP/2, HTTP/3, an async interface, and connection multiplexing — while keeping the same API, so import niquests as requests is the documented migration.

Concurrency model and stack#

Both, from a requests-shaped API. niquests.Session is synchronous and niquests.AsyncSession is asynchronous, which puts it alongside httpx as one of two libraries here offering the pair.

Its transport is a fork of urllib3 (urllib3-future) carrying the protocol work — HTTP/2 and HTTP/3 support live there rather than in the client.

Where it sits in the field#

This is the one library in the survey whose whole premise is another library’s stasis. requests is sixteen years old, synchronous and HTTP/1.1 by choice; niquests exists because a fraction of that user base wants the API and not the constraints.

It released on the day of this measurement — version 3.21.1, 2026-08-28 — which against httpx’s 21-month-old stable and requests’ three-month-old one makes it the most actively released client here.

Measured position#

2,449 stars against requests’ 54,258 — a fiftieth. Pure wheel, Python ≥3.7, which is the most permissive floor in the survey.

The star gap is the finding, and it points the same way as several in these three surveys: an argued technical case with a small fraction of the incumbent’s audience. Being API-compatible is what makes the case unusual — the switching cost is near zero, and adoption still has not moved.

Trade-offs#

You get requests’ API with HTTP/2, HTTP/3, async, and an active release cadence. For a codebase already full of requests calls that needs one of those, the migration is an import line.

You give up ecosystem certainty in a specific way: requests is depended on by an enormous amount of third-party code, and a drop-in fork is drop-in for your code, not for a library that imports requests itself. You take a fork of urllib3 as a transport, which is a second project to track.

Where it is weak: as a default for new code, where httpx has the larger community and the same feature list minus HTTP/3. Its case is strongest exactly where the API compatibility is worth something, which is an existing requests codebase.


Observed data#

Every figure in S1 comes from this table. Measured, not recalled.

Measured: 2026-08-28 Sources: PyPI JSON API (stable version, license, Python floor, upload dates, wheel kind); pypistats.org /recent; GitHub REST repos/<owner>/<repo>.

librarylayerstableuploadeddownloads/mostarswheelsPython
urllib3connection2.7.02026-05-071,826,681,7754,053pure≥3.10
requestsclient2.34.22026-05-141,730,118,93654,258pure≥3.10
h11protocol0.16.02025-04-24951,733,572566pure≥3.8
httpcoreconnection1.0.92025-04-24838,186,209549pure≥3.8
httpxclient0.28.12024-12-06836,604,83215,449pure≥3.8
aiohttpclient+server3.14.32026-07-23638,425,44816,531platform≥3.10
h2protocol4.4.12026-08-03206,952,8921,041pure≥3.10
curl-cffiimpersonation0.16.22026-08-2544,703,9756,405platform≥3.10
pycurlclient7.47.02026-06-295,252,8111,168platform≥3.10
treqclient (Twisted)26.7.02026-07-02213,937604pure≥3.9
niquestsclient3.21.12026-08-28not retrieved2,449pure≥3.7
primpimpersonation2.0.02026-08-26not retrieved578platform≥3.10

Two download figures are missing: pypistats rate-limited the collection and the retry did not reach them. They are marked rather than estimated.

On precision#

The table records what was measured; the prose rounds it — “1.8 billion”, not 1,826,681,775. A monthly download count moves daily, counts mirrors and CI, and depends on where the month boundary falls. Stars go to three figures, ratios to two. The exact values stay here with their date and source, because that is what makes the measurement re-checkable.

The stable-release trap#

uploaded is the upload date of info.version — the version pip install resolves to — and not the newest file on the index.

That distinction changes what this table says about httpx. PyPI’s newest httpx file is dated 2026-08-21 and is 1.0.dev5, a pre-release. Reading the newest file as the release date makes a stable that has not moved since December 2024 look like it shipped last week. The first pass of this collection did exactly that.

Downloads measure position in the dependency graph#

urllib3 leads at 1.8 billion a month and almost nobody installs it on purpose — it is requests’ transport, so its total is requests’ plus everything else that reaches it. requests itself is at 1.7 billion.

The same pattern runs down the httpx stack: httpcore at 838 million and h11 at 952 million against httpx’s 837 million. h11 exceeds httpx because other things reach it too. None of those three is a library anyone chooses.

aiohttp’s 638 million does not separate client use from server use, and nothing in the registry does. Survey 1.241 flagged the same figure from the server side.

The numbers that carry information are the ones where nothing forces the install: curl-cffi at 44.7 million, which is large for a specialist tool, and pycurl at 5.25 million against treq’s 214,000.

Two release dates worth putting side by side#

stable releaseage at measurement
httpx2024-12-0621 months
httpcore2025-04-2416 months
h112025-04-2416 months
requests2026-05-143 months
niquests2026-08-28same day

The entire httpx stack — client, connection layer, protocol implementation — has had no stable release in over a year. Its repository’s last commit is 2026-02-23, with 143 open pull requests — httpx has GitHub issues disabled and uses Discussions, so that count is PRs rather than bugs.


S1 verdict#

Five findings survive the reading pass.

1. The httpx stack has not shipped a stable release in over a year#

httpx’s stable is 0.28.1, uploaded 2024-12-06 — twenty-one months before this measurement. httpcore and h11, the two layers beneath it, both stopped at 2025-04-24. Last commit on httpx’s default branch: 2026-02-23, with 143 open pull requests — httpx has issues disabled and uses Discussions, so that number is contributions waiting, not bugs filed.

PyPI’s newest httpx file is dated 2026-08-21 and is 1.0.dev5, a pre-release. Reading that as the release date — which is the natural mistake and one this survey made on its first pass — makes a stalled stack look current.

2. Encode is not moving at one speed, and which projects left is the pattern#

Surveys 1.241 and 1.242 established that Starlette and uvicorn were transferred out of Encode to Kludex. Both shipped within the last week.

httpx and httpcore stayed at Encode. Neither has a stable release in over a year.

Three surveys have now touched this organization and the split runs the same way each time: the projects that moved are active, and the most-recommended modern HTTP client is one of the ones that did not.

3. requests has two defaults that every production use overrides#

No connection reuse from the module-level functions. requests.get() builds a session, uses it once, and discards it. Reuse requires requests.Session(), and S2 measures what skipping it costs.

No timeout at all. requests.get(url) against an unresponsive server waits forever. httpx defaults to five seconds; requests defaults to none, so every call site needs timeout= passed explicitly or the service has an unbounded hang in it.

Sixteen years of stability is the reason both are still there, and it is a real trade rather than an oversight.

4. Most of what people call “requests behavior” is urllib3’s#

Pooling, retry policy, TLS verification and keep-alive live one layer down. The Retry object a team eventually needs is urllib3’s, mounted on a requests adapter.

That matters twice over. urllib3 is the most-installed package in this survey at 1.8 billion a month and almost nobody installs it on purpose — its total is requests’ plus everything else that reaches it. And a library that only needs a few requests can depend on urllib3 directly and remove four packages from every consumer’s install.

5. Two libraries exist for a job the others cannot do at all#

curl-cffi and primp impersonate browser TLS fingerprints. A request from requests or httpx is identifiable as automated by its handshake before a single header is read, and against a target that acts on that, no amount of tuning the User-Agent helps.

curl-cffi at 44.7 million downloads a month is large for a specialist tool — comparable to the entire install base of several general-purpose clients — which says something about how often this problem is met.

Both cost platform wheels and a binary you did not build, and browser fingerprints move with browser releases, so impersonation is maintenance in a way ordinary HTTP is not.

Open questions#

Four things this pass could not settle by reading:

  1. What skipping the session actually costs, per client.
  2. Sync against async under concurrency. 1.241 established that a synchronous client blocks an async framework’s event loop; the size of that is measurable.
  3. What HTTP/2 buys against a single host, which is the common case.
  4. Whether httpx is slower than requests per request, as is often said.

What this pass does not conclude#

No client is recommended here. Release gaps, packaging and defaults are recorded as category facts; what they mean depends on who is calling, which is S3.


requests#

What it is#

The synchronous HTTP client almost every Python developer has used. Sixteen years old, the most-starred package in this survey by a factor of three, and the library whose API everything else here is measured against — httpx and niquests both copy it on purpose.

Concurrency model and stack#

Synchronous only, and that is the whole shape of the decision. There is no async interface and there will not be one; the project’s own position is that async belongs to a different library.

requests      the API — sessions, auth, redirects, cookies
  urllib3     connection pooling, retries, TLS, keep-alive
  certifi     the CA bundle
  charset-normalizer / idna

Most of what people attribute to requests is urllib3’s. Pooling, retry policy, TLS verification and connection reuse all live one layer down, which matters because the retry-and-backoff configuration a service eventually needs is set on a urllib3 object.

The default that catches services#

requests.get(url) — the module-level function — creates a new session, and therefore a new connection, for every call. Connection reuse requires requests.Session().

For a script this is invisible. For a service making repeated calls to the same host it is a TCP and TLS handshake per request, and it is the single most common performance defect in code using this library. S2 measures the size of it.

There is also no default timeout. requests.get(url) with an unresponsive server waits indefinitely. httpx defaults to five seconds; requests defaults to forever, and every production use needs timeout= passed explicitly at every call site.

Measured position#

54,258 stars — more than three times httpx’s 15,400 and the highest in the survey. Version 2.34.2, released 2026-05-14. Pure wheel, Python ≥3.10.

Sixteen years and still on 2.x, with an API that has barely changed. Whatever else is true, this is the most stable public interface in the category.

Trade-offs#

You get the most widely known API in Python, the largest body of existing answers of anything in this survey, sixteen years of production hardening, and a pure-Python install with a short dependency list.

You give up async entirely — and 1.241 established that an async framework calling a synchronous client blocks its event loop, which makes this a correctness question rather than a performance one. You give up HTTP/2. You take on two defaults that have to be overridden on every use: no connection reuse from the module-level functions, and no timeout.

Where it is weak: inside anything async, which is most new Python web services. For a script, a CLI, or a synchronous worker it remains the reasonable default.


urllib3#

What it is#

The connection layer under requests, and a usable client in its own right. Pooling, retries, redirect handling, TLS verification and keep-alive live here — so a large share of what people describe as requests behavior is urllib3’s behavior.

Concurrency model and stack#

Synchronous. A PoolManager holds connection pools per host and hands out connections; that is the whole model, and everything above it inherits it.

Nothing sits underneath except the standard library’s http.client and ssl. urllib3 is the bottom of the synchronous stack, which is why its dependency list is empty and why it appears in nearly every Python environment whether or not anyone chose it.

Why you would choose it directly#

Two cases, and both are narrower than they sound:

Retries as a first-class object. urllib3.Retry configures attempt counts, backoff, which status codes retry and which methods are safe to repeat. Through requests this is still the object you configure, mounted on an adapter — so a team writing serious retry logic is already using urllib3 and would gain by knowing it.

One fewer dependency. For a library that only needs to make a few requests, depending on urllib3 rather than requests removes four packages from every consumer’s install.

Measured position#

4,053 stars — low against requests’ 54,000, and the gap measures visibility rather than use: this is a dependency almost nobody installs on purpose. Version 2.7.0, released 2026-05-07. Pure wheel, Python ≥3.10.

urllib3 2.x was a genuine breaking change, and it is the one migration in this category with real consequences: it dropped older TLS behavior and changed several defaults, and environments pinned to 1.x are pinned for reasons somebody should re-check rather than inherit.

Trade-offs#

You get the smallest dependency footprint of any real client here, retry configuration that everything else exposes indirectly, and code that is already installed.

You give up the ergonomics that made requests famous — JSON handling, sessions with cookie persistence, and an API designed for application code rather than for a layer beneath one. You give up async entirely.

Where it is weak: as an application-level client. It is the right thing to reach for when writing a library or configuring retries, and a worse requests for everything else.

S2: Comprehensive

How these clients were measured#

Every client points at the same target: one uvicorn serving Starlette, answering GET /items/42 with 23 bytes — the same application and response as surveys 1.241 and 1.242, so all three sit on one axis. 1.241 varied the framework with no server; 1.242 varied the server with the application fixed; this varies the client with both fixed.

Recipe, pinned versions and raw output: bench/.

What was measured#

levelquestion
L2per-request cost with the connection reused
L3the same request made the casual way, without a session
L4N requests in flight — sync clients on threads, async on the loop

Seven rounds of 400 requests for L2 and L3, minimum of per-round means. 2,000 requests per run for L4. Every callable returns the response body, so a wrong answer fails the run instead of being timed as a right one.

Localhost is the point and the limit#

Client and server are on the same machine. That removes network variance, which is what makes the per-request column readable at all — and it means every session-versus-no-session figure is a floor.

A TCP handshake costs microseconds here. On a real network it costs a round trip, and over TLS a full negotiation. So the cost of skipping the session is worse in production than this survey measures, never better. That direction is stated with each figure rather than left to the reader.

Latency numbers are likewise a floor and should not be read as a forecast.

One axis at a time#

The application, the response, the target server and the machine are fixed; only the client moves. A number produced by varying two things measures the pair — the defect that makes most published client comparisons unusable, and the same rule surveys 1.241 and 1.242 were built on.

Two results were checked before being believed#

httpx’s casual call at 28× its session form was isolated to client construction (18,819 µs) and from there to ssl.create_default_context() (18,111 µs), rather than reported as an oddity.

httpx’s async client failing to scale was tested against the obvious confound — max_keepalive_connections defaults to 20 independent of max_connections, which would cause connection churn above 20. Raising it to match changed the figure by 0.9× and 1.0×, so that is not the cause. The cause was not isolated further, and the results file says so rather than offering an explanation the measurement does not support.

Reading the numbers#

aarch64, CPython 3.12.3, WSL2 on a laptop. curl-cffi and pycurl wrap C libraries, aiohttp ships a C parser, and the rest is interpreted Python — so these figures compare with each other and not with numbers measured on x86_64.

Not measured#

TLS on the wire, DNS, a remote host, HTTP/3, and proxies. The first three all strengthen the session argument and none reorders the per-request column by enough to matter; a remote-host measurement is deferred rather than approximated.


S2 measurement plan#

Written before S2, per Step 3.5. Levels ordered by cost. The default cut line — one container, no external service, no second machine — holds, with one clarification below.

A client needs something to talk to#

1.241 measured a framework with no server. 1.242 could not measure a server without a socket. A client needs both: a socket and something on the other end of it.

That is supplied rather than deferred. Survey 1.242’s bench already stands up nine servers and holds an application constant; this survey points its clients at one of them — uvicorn serving the same GET /items/42 returning 23 bytes — so the target is identical to what the other two surveys measured, and all three sit on one axis.

The target runs on the same machine, which removes network variance and means latency figures here are a floor, not a forecast. Every number is stated that way.

Open questions from S1#

  1. What does requests.get() cost against requests.Session()? S1 asserts a new connection per call. The size of that is the most actionable number this survey can produce.
  2. Sync against async at concurrency. 1.241 established that a synchronous client blocks an async framework’s event loop. The cost of getting it right, and of getting it wrong, is measurable.
  3. What does httpx’s HTTP/2 option actually buy against a single host, which is the common case?
  4. Is httpx slower than requests per request, as is often claimed, and does it matter next to connection reuse?

The levels#

levelsettlescostrungcovers
L0Registry factsversions, wheels, floors, cadence, stack depthdone in S1cited12
L1Install cost — wheel kind, transitive dependencies, import timewhat each client drags in; the zero-dependency claim~30 minmeasured-local12
L2One request, connection reused — session, warm poolper-request cost with the handshake amortized~2 hmeasured-local7
L3Session against no session — the requests.get() questionS1 question 1, and the most common defect in code using these~2 hmeasured-local5
L4Concurrency — N requests in flight, sync clients threaded, async clients on the loopS1 question 2; where each model stops scaling~4 hmeasured-local6
L5HTTP/2 on and off, one host and severalS1 question 3~2 hmeasured-localhttpx, niquests
L6TLS handshake cost, against a real certificatehow much of L3’s gap is TLS rather than TCP~2 hmeasured-local5
L7Against a remote host, over a real networkwhether any of this survives real latencyneeds a network peermeasured-local
L8Floor modelthe reader re-runs ita buildmeasured-browsersee below

The cut#

S2 takes L1 through L5. One container, the 1.242 rig supplying the target, no external service.

L6 and L7 are deferred. L7 is where this apparatus stops: every number here is against localhost, where a TCP handshake costs microseconds. On a real network it costs tens of milliseconds, which makes connection reuse more important than these measurements will show, not less. That direction is stated with every L3 figure rather than left for the reader to infer.

L8 is possible here, and that is worth checking rather than assuming#

Unlike 1.242, a client survey might support a floor model: a browser can make HTTP requests.

It cannot support this one. Pyodide’s networking goes through the browser’s fetch, so requests, httpx and aiohttp do not run there unpatched — the thing being compared is exactly the thing the sandbox replaces. Connection pooling, TLS handshakes and HTTP/2 negotiation are the browser’s, not the library’s.

So the answer is no, and for a different reason than 1.242’s. 1.242 had no socket to listen on; here there is no socket to dial. Both are the same boundary seen from opposite sides, and the check is written down rather than assumed.


S2 verdict#

Four findings, and one correction to a widely repeated claim.

1. httpx.get() costs 28× its own session form, and the cause is an eager SSL context#

22,021 µs against 783. Every other client’s casual-call penalty is 2–2.5×, so this was isolated rather than reported:

µs
httpx.Client() — construction alone, no request18,819
ssl.create_default_context()18,111
requests.Session() — construction alone24.7

httpx builds an SSL context when the client is constructed; requests defers it until an HTTPS request needs one. So httpx.get() loads the system certificate bundle on every call — including against a plain http:// URL that will never use TLS. A thousand-iteration loop spends eighteen seconds loading the same certificates.

Both libraries have the same footgun. In one of them it is three orders of magnitude deeper.

2. Skipping the session costs 2× on localhost and more in production#

requests 2.0×, niquests 2.2×, urllib3 2.5×, stdlib 1.1×.

These are floors. A TCP handshake is microseconds on localhost and a network round trip in production; over TLS it is a full negotiation. The measurement understates the defect it is measuring, and that direction is stated with the table.

3. aiohttp scales 10× with concurrency; httpx’s async client does not scale at all#

aiohttp 1,475 → 15,185 rps from concurrency 1 to 32. httpx async 824 → 719, and slower at 128 than at 1. A 21× gap at concurrency 32 between two libraries doing the same job.

The obvious confound was tested and ruled out. The cause was not isolated, and anyone choosing httpx for async work should measure their own case rather than trust this number or its reputation.

Separately: every synchronous client plateaus below 1,950 rps however many threads are pointed at it. Threads stop a synchronous client blocking; they do not give it async throughput.

4. HTTP/2 buys nothing against a single sequential caller#

786 µs against 783. One request at a time over a reused connection has nothing to multiplex, and HTTP/1.1 keep-alive already does that well.

This is not a verdict on HTTP/2 — it is a verdict on enabling it for the case most people are in when they consider it.

The correction#

httpx is faster per request than requests, not slower: 783 µs against 1,044, with a session on both sides. requests is the slowest client measured here.

The whole field spans 1.8×, which is the smallest term in this survey and the one with the most opinions attached to it.

What is not measured#

TLS on the wire, DNS, a remote host, HTTP/3 and proxies. A remote-host measurement is the one that would change the size of finding 2 — upward — and it is deferred rather than approximated.


L2 and L3 — the cost of a request, and the cost of skipping the session#

One uvicorn on localhost serving the same 23-byte response as surveys 1.241 and 1.242, so all three sit on one axis. Seven rounds of 400 requests, minimum of per-round means. Raw: bench/results/l2_l3.json.

L2 — connection reused#

clientµs/request
curl_cffi587
urllib3756
httpx783
httpx + HTTP/2786
stdlib urlopen956
niquests976
requests1,044

requests is the slowest client measured, and httpx is 1.33× faster than it. The claim that httpx costs more per request than requests does not survive a session-to-session comparison on this workload.

The spread across the whole field is 1.8×, which for most callers is nothing against the latency of a real network.

HTTP/2 buys nothing here#

783 µs against 786 µs. Against a single host over one connection, which is the common case, HTTP/2’s multiplexing has nothing to multiplex — one request at a time over a reused connection is exactly what HTTP/1.1 keep-alive already does well.

HTTP/2’s advantages are concurrent streams to one host and header compression on large headers. Neither is exercised by this workload, and this measurement should not be read as a verdict on HTTP/2 — it is a verdict on enabling HTTP/2 for a single sequential caller, which is where most of the interest in it comes from and where it changes nothing.

L3 — the same request without a session#

clientsessioncasual callmultiple
stdlib urlopen9561,0261.1×
requests1,0442,1232.0×
niquests9762,1242.2×
urllib37561,8842.5×
httpx78322,02128.1×

requests.get(), httpx.get() and niquests.get() are module-level conveniences that build a client, use it once and discard it. Real code is full of them.

On localhost the penalty is 2-2.5× for most clients. On a real network it is worse, not better: what the session saves is a TCP handshake and, over TLS, a full negotiation — costs that are microseconds here and a round trip in production. Every figure in this table is a floor.

httpx.get() costs 28× its own session form, and the cause is specific#

Twenty-two milliseconds for a request the same library serves in 783 microseconds through a client. That is far outside the pattern, so it was isolated rather than reported as a curiosity:

µs
httpx.Client() — construction alone, no request18,819
ssl.create_default_context()18,111
requests.Session() — construction alone24.7
niquests.Session() — construction alone52.7

httpx builds an SSL context when the client is constructed. requests defers it until an HTTPS request needs one. Loading the system CA bundle is 18 milliseconds on this machine, and httpx.get() pays it on every call — against a plain http:// URL that will never use TLS at all.

So the two libraries have the same footgun and it is three orders of magnitude worse in one of them. A loop calling httpx.get() a thousand times spends eighteen seconds loading the same certificate bundle a thousand times.

The fix is the one that was already correct: construct a client once and reuse it. What changes is the size of the mistake.

What this level does not cover#

Localhost only. No TLS on the wire, no DNS, no real latency. Those all make the session argument stronger and none of them reorder the L2 column by enough to matter — but L7, a measurement against a remote host, is deferred and stated as such.


L4 — concurrency: sync on threads, async on the loop#

2,000 requests against one uvicorn on localhost, sync clients driven by a thread pool and async clients by the event loop. The handler is fast, so this measures the client’s concurrency machinery rather than the server’s waiting. Raw: bench/results/l4.json.

Async#

clientc=1c=8c=32c=128
aiohttp1,47510,40515,1859,130
niquests8482,8592,8572,619
httpx824983719609

Sync, on a thread pool#

clientc=1c=8c=32c=128
urllib31,1591,9471,6261,563
niquests6991,2561,0461,096
httpx1,0451,2091,0911,195
requests844912899758

aiohttp scales and httpx does not#

aiohttp goes from 1,475 to 15,185 requests a second — a 10× gain from concurrency. httpx’s async client goes from 824 to 719, and at 128 it is slower than at 1.

At concurrency 32 the gap is 21×, between two libraries doing the same job against the same server with the same event loop.

That result is far enough outside the pattern to be a configuration mistake, so the obvious confound was tested: httpx’s max_keepalive_connections defaults to 20, independent of max_connections, which would cause connection churn above 20. Raising it to match the concurrency changed the figure by 0.9× and 1.0× — it is not the keepalive limit.

The cause was not isolated further. What is measured is that httpx’s async client does not gain from concurrency on this workload and aiohttp gains an order of magnitude, with the most likely explanation ruled out. Anyone choosing httpx for async work should measure their own case rather than take either this number or its reputation.

Threads are not a substitute for async#

Every synchronous client plateaus between 750 and 1,950 requests a second regardless of how many threads are pointed at it. urllib3 peaks at 1,947 and falls back; requests never exceeds 912.

aiohttp at concurrency 32 does 15,185 — roughly eight times the best synchronous result. The thread pool is not a way to get async throughput out of a synchronous client; it is a way to stop one blocking, which is a different and smaller benefit.

This is the client-side half of 1.241’s finding. There, a synchronous handler in an async framework was capped by a 40-slot thread pool. Here, a synchronous client under threads is capped an order of magnitude below what an async client reaches — and a service doing both pays both.

The uncomfortable pairing#

1.241’s persona 4 — a service whose real work is calling other services — was pointed at aiohttp on the strength of it being client and server in one library. L4 says the client half is also, by a wide margin, the fastest async client measured.

Two independent lines of evidence landing on one library, from surveys that were not looking for the same answer.

S3: Need-Driven

The six situations#

the situationwhat eliminates optionslands on
1Inside an async servicea sync client blocks the event loopaiohttp, or httpx measured first
2A script or a CLInothing; familiarity winsrequests
3A library other people installevery dependency is inherited by consumersurllib3, or the stdlib
4A service calling one host, oftenconnection reuse, and a timeoutany client, with a session
5Requests that are being refusedTLS fingerprint, not headerscurl-cffi
6An existing requests codebase needing asyncthe call sites already existniquests, or a rewrite to httpx

The verdict sets out where these conflict.

What decides a client, in order#

Measured across two levels, the things that actually change an outcome rank like this:

  1. Whether you reuse the connection. 2× on localhost for most clients, 28× for httpx, and more than either on a real network.
  2. Whether the client matches your concurrency model. aiohttp reaches 15,185 requests a second where every synchronous client plateaus below 2,000.
  3. Whether a timeout is set. requests has no default, and an unbounded hang is worse than any latency in this survey.
  4. Which client, among those that fit. 1.8× across the whole field per request, which is the smallest term on this list and the one with the most opinions attached.

The order is upside down relative to how the choice is usually argued.


S3 verdict#

The grid#

situationlands on
1Inside an async serviceaiohttp; httpx only after measuring your own case
2A script or CLIrequests, with timeout=
3A library others installurllib3, or the standard library
4Calling one host, oftenany client, with a session
5Requests being refusedcurl-cffi
6requests codebase needing asyncniquests, or threads if the need is modest

Where these conflict#

Situation 1 against situation 6. Both need async, and they land on different libraries because one is choosing a client and the other is choosing a migration. aiohttp is 5× niquests at concurrency 32 and costs every call site; niquests is an import line. Neither answer is wrong for the other’s reader.

Situation 2 against situation 3. requests is the right answer for a script and the wrong one for a library, and the same property drives both — its four dependencies are invisible to a script author and inherited by every consumer of a package.

What the measurements changed#

Across six situations, the per-request ranking changed no recommendation at all. The whole field spans 1.8×, which is smaller than every other term that came up.

Two measurements did decide things:

The session gap — 2× to 28× — decided situation 4 entirely, and it decided it in favor of an idiom rather than a library. That is unusual for a survey like this and it is the most useful thing here.

The concurrency gap decided situations 1 and 6. aiohttp at 15,185 rps against httpx’s 719 is 21×, and against the best synchronous client on threads it is 8×.

And one measurement decided nothing but corrects a common claim: httpx is faster per request than requests with a session, 783 µs against 1,044. The reputation runs the other way.

Who this category serves badly#

  • A codebase that is half sync and half async. httpx is the only client offering the same API both ways, and its async half measured worst. niquests offers the pair too, at a fiftieth of the adoption. The combination of symmetry and throughput does not exist.
  • Anyone wanting HTTP/3 without a fork. niquests has it; nothing mainstream does.
  • A library author who needs async and zero dependencies. aiohttp is a platform wheel, httpx brings three packages, and the standard library has no async HTTP client at all.

Situation 1 — Inside an async service#

Who#

A FastAPI, Starlette or Litestar service that calls other services — the gateway shape survey 1.241 called persona 4, and the most common place outbound HTTP happens in new Python code.

What eliminates options#

A synchronous client blocks the event loop. Survey 1.241 established this as a correctness problem rather than a performance one: an async def handler calling requests.get() stops the loop for the whole request, and the concurrency the framework exists to provide disappears.

That removes requests, urllib3, pycurl and the stdlib outright. Not because they are slow — because they are wrong here.

What the measurements say#

aiohttp reaches 15,185 requests a second at concurrency 32. httpx’s async client reaches 719. A 21× gap between the two obvious candidates, doing the same job against the same server.

httpx’s async throughput does not improve with concurrency at all — 824 at c=1, 609 at c=128. The obvious confound was tested and ruled out (max_keepalive_connections), and the cause was not isolated further, so this is a measurement rather than an explanation.

For scale: every synchronous client on a thread pool plateaus below 1,950 rps. The gap between “wrong client, mitigated with threads” and “right client” is roughly eight times.

Where it lands#

aiohttp, on two independent grounds that arrived from different directions: 1.241 pointed this persona at aiohttp because it is client and server in one library with shared connection handling, and L4 finds it is also by a wide margin the fastest async client measured.

httpx if the sync/async symmetry is worth something — the same code shape working both ways is a real advantage for a codebase that is partly each — and measure it on your own workload first, because this survey’s number is poor and its cause is unexplained.

niquests at 2,859 rps sits between them and brings a requests-shaped async API.

Not requests, ever, inside an async handler. If it is already there, it belongs in run_in_executor rather than on the loop, and 1.241’s L4 measured that thread pool at forty slots.


Situation 5 — Requests that are being refused#

Who#

Code that needs to fetch something from a site actively distinguishing automated traffic from browsers: a price checker, an availability monitor, a research scraper, an integration with a service that has no API.

What eliminates options#

The TLS handshake, before any header is read. Cipher order, extensions, ALPN and HTTP/2 settings frames form a fingerprint, and a Python client’s differs from Chrome’s. A service acting on that has already decided before it sees the User-Agent.

So the constraint eliminates every general-purpose client at once, and no amount of header tuning changes it. This is the one situation in the survey where the question is whether the request works at all, not how fast it is.

What the measurements say#

Almost nothing, and that is the point. curl-cffi measured 587 µs per request — the fastest client in L2 — and that is irrelevant to why anyone reaches for it. Speed is not the proposition; acceptance is.

The relevant S1 data is adoption: curl-cffi at 44.7 million downloads a month is large for a specialist tool, comparable to the entire install base of several general-purpose clients. That number is a measure of how often this problem is met.

Where it lands#

curl-cffi. Bindings to curl-impersonate, a requests-shaped API, sync and async, and HTTP/2 free from libcurl. It is the established answer.

primp is the same idea with a Rust core — 578 stars against curl-cffi’s 6,405, at 2.0 after arriving recently. The same wager granian makes one survey over, at an earlier stage.

The costs, which are real#

Platform wheels, so a wheel must exist for the target. A binary you did not build, which is a supply-chain consideration a pure-Python client does not raise. And browser fingerprints move with browser releases, so impersonation is ongoing maintenance rather than a setting.

Worth being plain about the other thing: a site that has gone to the trouble of fingerprinting TLS has expressed a preference. Whether to work around it is a question this survey has no view on, and the answer is not technical.


Situation 4 — A service calling one host, often#

Who#

A service whose work includes repeated calls to the same upstream: an API gateway, a webhook relay, a job worker polling a queue endpoint, anything that talks to one host thousands of times an hour.

What eliminates options#

Connection reuse, and it eliminates an idiom rather than a library. Every client here does the right thing when handed a session and the wrong thing when called through its module-level convenience function.

What the measurements say#

This is the situation the measurements were built for.

clientsessioncasual callmultiple
requests1,044 µs2,1232.0×
niquests9762,1242.2×
urllib37561,8842.5×
httpx78322,02128.1×

And these are floors. On localhost a TCP handshake costs microseconds; over a real network it costs a round trip, and over TLS a full negotiation. The same defect is worse in production than this table shows.

httpx’s 28× has a specific causehttpx.Client() builds an SSL context on construction, 18.8 ms of which 18.1 ms is ssl.create_default_context(), paid on every httpx.get() even for a plain http:// URL. A loop of a thousand httpx.get() calls spends eighteen seconds loading the same certificate bundle.

The second measurement for this persona: HTTP/2 buys nothing. 786 µs against 783. One caller sending one request at a time over a reused connection has nothing to multiplex.

Where it lands#

Any client, with a session. That is the whole recommendation, and it matters more than the choice of library: the gap between the fastest and slowest client here is 1.8×, and the gap between using a session and not is 2× to 28×.

A timeout on every call. requests has no default and an unbounded hang in a hot path is a worse failure than any latency measured here.

Not HTTP/2 for this shape, unless the workload is concurrent streams to one host — which is a different situation from this one.


Situation 3 — A library other people install#

Who#

You are writing a package that makes HTTP requests, and it will be installed alongside somebody else’s application, whose other dependencies you cannot see.

What eliminates options#

Every dependency you take is inherited by every consumer, along with its version constraints. A library that pins a client aggressively can make itself uninstallable next to another library that pins the same thing differently, and the person who resolves that conflict is a user of both.

That reverses the usual ranking. Ergonomics are yours to live with; the dependency tree is someone else’s problem.

What the measurements say#

Little, and the relevant data is S1’s rather than S2’s:

urllib3 is already installed almost everywhere — 1.8 billion downloads a month, most of it as requests’ transport. Depending on it adds nothing to a typical environment.

requests adds four packages — urllib3, certifi, charset-normalizer, idna — every one of which a consumer inherits.

The standard library adds nothing at all, and urllib.request can make an HTTP request correctly. It is verbose and its defaults are poor, but a library making three requests can absorb that where its users cannot absorb a dependency conflict.

Where it lands#

urllib3 for most libraries. It is the transport everything else already uses, it is pure Python, its dependency list is empty, and it exposes the retry configuration a library should be setting anyway.

The standard library where the package advertises zero dependencies, which is a real selling point for anything meant to install everywhere.

requests or httpx only if the library’s own surface needs sessions, cookie persistence or content negotiation — and then pin loosely, because a tight pin on a package this widely installed is a conflict waiting to happen.

Not aiohttp, unless the library is async-only by design: it is a platform wheel, which narrows where a consumer can install it.


Situation 6 — An existing requests codebase that needs async#

Who#

A working synchronous application, some years old, full of requests calls, that now has a reason to make outbound calls concurrently — a new integration, a batch that has grown, a service being moved behind an async framework.

What eliminates options#

The call sites already exist, and there may be hundreds. The cost is not learning a new client; it is touching every place the old one is used, and testing what that changed.

That reframes the choice. This persona is choosing a migration, and the candidates differ mostly in how much of the codebase they force open.

What the measurements say#

niquests is API-compatible: import niquests as requests is the documented path, and AsyncSession is the async half. It released the day of this measurement, against httpx’s 21-month-old stable — the most actively released client here.

At concurrency it measured 2,859 rps async, between aiohttp’s 15,185 and httpx’s 719.

httpx is a rewrite of every call site. Similar shape, different library, and its async client measured poorest of the three.

aiohttp is a larger rewrite — no requests-shaped API at all — and by a wide margin the fastest.

Where it lands#

niquests when the codebase is large and the async requirement is real but not extreme. The switching cost is close to zero and the throughput is four times httpx’s.

aiohttp when the async work is the point of the change and the codebase is small enough to rewrite. It is 5× niquests and 21× httpx at concurrency 32.

Stay on requests and use a thread pool when the concurrency needed is modest. This is under-given advice: every synchronous client here plateaus below 1,950 rps on threads, which is enough for a great many services, and it costs no migration at all. Survey 1.241’s L4 measured the ceiling that arrangement runs into — forty threads — so it is a real answer with a known limit rather than a dodge.

The caution on drop-in#

niquests is drop-in for your code. It is not drop-in for a third-party library that imports requests itself, and a large application usually contains several. Both end up installed. That works, and it is less tidy than the pitch implies.


Situation 2 — A script or a CLI#

Who#

Something run by a person, occasionally: a deployment helper, a data pull, a one-off integration, the --check flag on an internal tool. It makes a handful of requests and exits.

What eliminates options#

Nothing, and that is the finding. At a handful of requests, every measurement in this survey is invisible. The 1.8× spread in per-request cost is microseconds against a process that a human is waiting on.

So the constraint is not technical. It is what the next person to open the file will recognize, and how much of the ecosystem’s documentation applies.

What the measurements say#

They say to ignore them. A script making ten requests cannot observe the difference between the fastest and slowest client here, and the session-versus-no-session gap that dominates situation 4 is 20 milliseconds total.

One measurement does apply, in a way scripts feel: httpx.get() costs 22 ms per call because it constructs an SSL context each time. Ten calls is a fifth of a second of loading the same certificate bundle ten times. Still invisible to a person, and worth knowing before the script grows into a loop over a thousand items.

Where it lands#

requests. The most widely known API in Python, the largest body of existing answers of anything measured, sixteen years of stability, and a pure-Python install.

This is the situation where its two bad defaults cost least — a script that hangs is noticed and killed by the person running it, and connection reuse across four requests is worth nothing. Pass timeout= anyway; it is one argument and it turns a hang into an error.

httpx is equally fine here and brings a timeout by default. It is the better-designed library and this is the situation where that matters least.

Not niquests, curl-cffi or pycurl — each solves a problem this reader does not have, at the cost of a dependency the next person will not recognize.

S4: Strategic

Which of these will still be maintained in five years#

Every other comparison in this survey assumes the library still exists. These are the signals that bear on whether it will.

What was measured#

GitHub REST API, 2026-08-28. Rung cited. Contributor concentration as a bus-factor proxy; issue close ratio using the search API with type:issue so pull requests are excluded; last commit on the default branch; release dates from S1’s PyPI data.

Two places the numbers need a note before they are read#

httpx has GitHub issues disabled and uses Discussions instead. Its close ratio is therefore 0 open and 0 closed, which means nothing at all — and its open_issues_count of 143 is entirely pull requests. A survey that reported “143 open issues” would be describing something that does not exist, and this one did on its first pass.

The same shape appeared in survey 1.241, where Django’s GitHub issue counts are zero because Django tracks bugs in Trac. A zero in this column is a question, not a finding.

primp has one contributor, so its 100% concentration is arithmetic rather than a signal about governance. It is a young project by one author, which is a different risk from a mature project that lost its others.

How to read a bus factor here#

A high top-one share reads differently depending on what stands behind the individual — a foundation, an organization with several projects, or nobody. What follows says which case each library is in, because the number alone cannot.


httpx — viability#

Position: the whole stack is quiet, and it is the library the ecosystem currently recommends.

signalvalue
stable release0.28.1, 2024-12-06 — 21 months
last commit2026-02-23
open pull requests143 (issues are disabled)
top-1 commit share53.5%
httpcore, beneath itstable 2025-04-24, last commit 2025-10-13
h11, beneath thatstable 2025-04-24, 68.5% close ratio, 70.1% top-1
downloads837 million a month

Three layers, all quiet, and a 1.0 that has not landed#

The pattern only appears if all three are read together. httpx has not released a stable version since December 2024; httpcore and h11 both stopped in April 2025. This is not one project pausing — it is a stack.

A 1.0 exists as 1.0.dev5, uploaded 2026-08-21. It has been in pre-release without a stable cut, and the default branch has not been committed to since February.

The 143 figure is pull requests, not issues. httpx has GitHub issues disabled and uses Discussions, so open_issues_count counts PRs only. That is the sharper reading of the two: 143 contributions arriving at a branch whose last commit is six months old.

The Encode split#

Surveys 1.241 and 1.242 established that Starlette and uvicorn were transferred out of Encode to Kludex, and both shipped within the last week. httpx and httpcore stayed, and neither has a stable release in over a year.

Three surveys have now touched this organization from different directions and the division is consistent: the projects that moved are active and the projects that remained are not. What that reflects is not something this survey can settle, and the practical consequence is the same either way.

What it does not mean#

httpx works. Nothing measured here found a defect in it, and per-request it is faster than requests (783 µs against 1,044). Its sync/async symmetry is a real design advantage that nothing else offers at its adoption level.

It means the library recommended as the modern replacement for requests is, on release cadence, the least active general-purpose client in this survey — while requests itself committed three days before this measurement and closes 96.5% of its issues.

That inversion is worth carrying into any recommendation, and S3 does: situation 1 sends readers to aiohttp and tells anyone choosing httpx to measure their own case first.

What would change this#

A stable 1.0, and commits on the default branch. Both are cheap signals to check and neither has appeared in six months.


S4 verdict#

The inversion#

The library the ecosystem recommends as the modern replacement is the least actively maintained general-purpose client here. The one it replaces is among the healthiest.

last commitstable releaseclose ratio
httpx2026-02-232024-12-06issues disabled
requests2026-08-252026-05-1496.5%
aiohttp2026-08-282026-07-2395.1%
urllib32026-08-262026-05-0790.2%
niquests2026-08-282026-08-28100%

And httpx’s stack agrees with itself: httpcore last committed 2025-10-13, h11 2025-04-24. Three layers, all quiet, with a 1.0 sitting in pre-release since August.

This does not make httpx a bad library — S2 measured it as faster than requests per request. It makes the common advice one release cycle out of date, and nobody’s adoption figure shows it.

The Encode split, seen from a third survey#

1.241 found Starlette transferred from Encode to Kludex. 1.242 found uvicorn transferred the same way. This survey finds httpx and httpcore still at Encode and not moving.

Three surveys, three different starting questions, one consistent division: what left is active, what stayed is quiet. Whatever the cause, a reader depending on any Encode project should check its own release dates rather than the organization’s reputation.

Strategic verdicts#

libraryfive-year outlookwhat would cost you
requestssafest here — 30.8% top-1, 96.5% close, 16 yearsno async, ever; two defaults to override
urllib3safest by concentration — 28.8% top-1, most distributednot an application-level API
aiohttphealthy and active — commits daily, 95.1% closeasync only; platform wheel
niquestshealthiest signals, smallest audience — 100% close1/50th of requests’ adoption; forked transport
httpxstalled, not dead — good code, no releasesa stack that has not shipped in a year
curl-cffiactive in its niche — 93.5% close, commits dailyfingerprints move; platform wheel
pycurlstable, niche — 99.8% closea C API in Python clothing
h11the quiet dependency — 68.5% close, 70.1% top-1it is under httpx, not chosen
treqmaintained, shrinking — 69.7% closeTwisted only
primpone author — 100% concentration, arithmetic not governancetoo early to depend on

The concentration nobody counts#

h11 is 70.1% one contributor, closes 68.5% of its issues, and last committed 2025-04-24 — and it is the HTTP/1.1 protocol implementation under httpx, drawing 952 million downloads a month.

Surveys 1.241 and 1.242 found the same shape twice: uvloop and httptools carrying 2.8× of uvicorn’s throughput while maintained separately, and Starlette and uvicorn sharing one maintainer. The Python HTTP ecosystem’s load-bearing layers are consistently smaller projects than the ones people choose between, and none of them appear in a comparison.

The one place the field has no answer#

Sync and async from one API, with throughput. httpx offers the symmetry and measured worst on async; niquests offers it at a fiftieth of the adoption; aiohttp has the throughput and no sync path at all. A codebase that is truly half each has no good option, and this is the gap most likely to be filled next.


requests — viability#

Position: the safest library in this survey, and the reason is the thing it is criticized for.

signalvalue
top-1 commit share30.8%
top-361.5%
close ratio96.5% (148 open, 4,023 closed)
last commit2026-08-25
stable release2.34.2, 2026-05-14
stars54,258 — highest in the survey
downloads1.73 billion a month

Sixteen years and a stable API#

requests is on 2.x with an interface that has barely moved, and that is a deliberate position rather than neglect: the project declined async, declined HTTP/2, and declined the API churn that would have come with either.

The criticism and the safety are the same fact. A library that adds nothing cannot break anything, and 1.73 billion monthly downloads is what sixteen years of not breaking looks like.

The health signals are all good#

30.8% single-contributor share is the third-lowest here. A 96.5% close ratio across 4,171 issues is a tracker that has been worked for a decade. The last commit was three days before this measurement.

Compare the library most often proposed as its replacement: httpx’s last commit was 2026-02-23 and its last stable release December 2024.

What you are accepting#

No async, permanently. Survey 1.241 established that this is a correctness problem inside an async framework, not a preference — so requests is disqualified from a growing share of new Python services by a decision its maintainers have made and will not revisit.

No HTTP/2, though S2 measured that as worth nothing against a single sequential caller.

Two defaults that need overriding: no connection reuse from the module-level functions (2× measured, more on a real network) and no timeout at all.

The strategic read#

For synchronous code, requests is the lowest-risk choice available and nothing in this survey argues otherwise. Its ceiling is architectural rather than institutional: it will still be here in five years, and it will still not do async.

That makes the interesting question not whether to trust it but whether the code calling it will still be synchronous — which is a question about the service, not the client.


Viability signals#

GitHub REST API, 2026-08-28. Issue counts use type:issue, excluding pull requests.

librarycontributorstop-1top-3openclosedclose ratiolast commitstable released
urllib3100+28.8%51.3%1351,24090.2%2026-08-262026-05-07
niquests100+28.3%58.2%0158100%2026-08-282026-08-28
requests100+30.8%61.5%1484,02396.5%2026-08-252026-05-14
aiohttp100+34.9%63.1%1603,08095.1%2026-08-282026-07-23
curl-cffi6846.7%79.9%3652093.5%2026-08-282026-08-25
pycurl100+48.6%81.6%141899.8%2026-08-222026-06-29
treq4942.6%68.5%5412469.7%2026-08-192026-07-02
httpcore5536.6%75.6%1117594.1%2025-10-132025-04-24
h113370.1%82.5%286168.5%2025-04-242025-04-24
primp1100%100%25196.2%2026-08-262026-08-26
httpx100+53.5%75.8%issues disabled2026-02-232024-12-06

The column that does not apply#

httpx’s issue counts are blank because the project has GitHub issues turned off and uses Discussions. Its open_issues_count of 143 is entirely pull requests. Reporting a 0% close ratio would be reporting the absence of a tracker as a failure to use one.

primp’s 100% concentration is one contributor, which is arithmetic and not governance.

The contrast the table is for#

requests, urllib3 and aiohttp are in good health on every signal available: low concentration, close ratios above 90%, and commits within the last three days. urllib3’s 28.8% top-1 and 51.3% top-3 are the most distributed in the survey.

httpx’s stack is the outlier, and all three layers agree: httpx last committed 2026-02-23 and last released stable in December 2024; httpcore last committed 2025-10-13; h11 last committed 2025-04-24 with a 68.5% close ratio and 70.1% single-contributor share.

niquests closes 100% of its issues — 158 closed, none open — and released on the day of measurement. On maintenance signals alone it is the healthiest project here, at a fiftieth of requests’ adoption.

Published: 2026-08-28 Updated: 2026-08-28