HTTPS benchmarks
We compare Python HTTP clients against a local Rust TLS echo server, using HTTPS over HTTP/1.1 and HTTP/2. Each request uploads a body and reads the echoed response to EOF. Async and blocking clients have separate charts.
The results include wreq, ry, pyreqwest, httpx, aiohttp, niquests,
curl_cffi, requests, and pycurl, at the versions recorded with each run.
Latest measurements
Choose a workload in the chart below. The revision and date beside each chart identify the code measured, which may differ from the docs revision.
Measurement environment
Hardware, Python and test settings used for these measurements.
| Item | Value |
|---|---|
| Python | CPython 3.14.6 (main, Jul 23 2026, 14:45:24) [Clang 22.1.3 ] |
| OS | Linux-6.18.40.1-microsoft-standard-WSL2-x86_64-with-glibc2.39 |
| Architecture | x86_64 |
| CPU | AMD Ryzen 9 9950X 16-Core Processor; 32 logical CPUs |
| Python event loop | asyncio |
| Server | Controlled Rust TLS echo server; 4 workers |
| TLS | TLS 1.3; certificate verification disabled for the local test server |
| Repeats | Rounds: 3; timed batches/round: 1; timed requests/batch: 300; warm-up batches/round: 1; warm-up requests/batch: 200 |
| Stream upload chunk | Varies by payload; recorded in raw JSON |
Async clients · Measured 371a02d082d2 · 2026-10-07 23:12 UTC
Blocking clients · Measured 371a02d082d2 · 2026-10-07 23:12 UTC
THROUGHPUT BY WORKLOAD
Compare clients
Pick a body size, then adjust the filters to compare clients. Full and Stream refer to the upload; we read every response to the end.
1 KiB body · Async API · HTTP/1.1 · Full upload · Concurrency 2 · requests/s
Unit: requests/s (RPS). Higher is better. Each chart uses its own zero-based linear scale; compare the numbers, not bar lengths across different charts.
Reading the results
RPS is the total number of completed requests divided by the total timed seconds across all rounds. Higher RPS means greater throughput for the selected workload. All timed samples contribute to the result; warm-up requests are excluded.
Full and Stream describe the upload. Every response is streamed to EOF. N/A means a client API does not support that combination; the benchmark does not substitute HTTP/1.1 or a buffered upload.
Compare clients within the same workload. Async clients share one client and connection pool, while blocking clients use one per worker. Read buffering and chunk boundaries also differ between libraries, so these results reflect the configured adapters, not every library's fastest possible configuration. Larger read chunks can substantially affect large-body HTTP/2 throughput.
When comparing runs, check the CPU, operating system, architecture, logical CPU count and test settings. Other programs can affect CPU and I/O availability. Short fixed batches can also include worker startup and scheduling overhead. Small differences may be noise; repeat measurements under matching conditions before treating a change as a regression.
The raw JSON includes individual timings, per-round rates, runtime settings, interpreter details, the source revision and native artifact hashes. Payload sizes use binary KiB/MiB. Response throughput uses decimal MB/s and counts only the response payload, although the timer covers both upload and download.
What is measured
The echo server collects the upload before returning the same payload. The timer covers the complete HTTPS exchange, including response consumption. It does not isolate TLS reads or writes, handshakes, or first-byte latency.
| Setting | Behavior |
|---|---|
| Full upload | One complete in-memory request body |
| Stream upload | An iterator or upload read callback over prepared chunks; chunk sizes are recorded in the JSON |
| Response | Streamed to EOF, without complete-body aggregation |
| Concurrency | A fixed number of workers; each starts its next request after EOF |
| Client lifetime | One isolated process per variant; clients are reused within each case and round |
| Timing | Upload, server handling and complete response consumption; payload preparation is outside the timer |
| Validation | HTTP status, actual protocol and echoed body length are checked |
| Repetition | Case order is shuffled; warm-up batches are excluded from reported rates |
The default suite tests seven body sizes from 1 KiB to 4 MiB, with Full and Stream uploads at concurrency 2, 10, 50, and 100. Each case runs for three rounds, with 200 warm-up requests and 300 timed requests per round. Historical snapshots may use different budgets or cover fewer cases; the charts show the cases actually recorded.
Runtime and client differences
wreq runtimes
| Label | Runtime used in the benchmark |
|---|---|
| MT | Shared multithreaded runtime, with workers set by available CPU parallelism |
| ST | Runtime(scheduler=Scheduler.PER_WORKER, workers=1); blocking clients share this runtime within a case |
| CT (blocking only) | Each client has its own current-thread runtime, driven by the calling thread |
These labels describe the Rust runtime. Blocking calls still run concurrently in a persistent Python thread pool, with one client and connection pool per logical worker. Async cases reuse one shared client. This changes scheduling and HTTP/2 multiplexing between the two APIs.
ry uses its default runtime. The pyreqwest variants use single-threaded and
multithreaded runtimes. Historical charts only show variants present in their
recorded data.
Response reading
wreq uses response.stream(). ry uses response.stream() without a minimum
chunk size, and pyreqwest uses response.body_reader.read_chunk() with initial
read buffering disabled. The remaining adapters use natural chunks where
available, or 64 KiB reads where the API requires a size.
Internal read-ahead, allocation, scheduling and chunk boundaries can differ even when libraries use the same requested read size.
Run the benchmarks yourself
Follow the benchmark instructions to build locally and choose a full suite or a smaller smoke run. The suite runs locally, outside GitHub Actions.
The runner saves logs, raw JSON and an English report. It saves a result snapshot only after every case finishes and passes validation; incomplete or timed-out runs do not produce one.
Publishing results to the docs
Completed runs keep their JSON in
bench/data.
After reviewing a complete suite, use --input RUN.json --publish.
For a complete blocking run, use --input RUN.json --publish-blocking.
Both build the docs before selecting the saved snapshot, without measuring again.
Docs builds validate the checked-in data and generate charts, metadata and
frozen JSON copies. A separate latest-blocking.json supplies the blocking
charts while async charts keep their own measurements. Each group shows its
revision and collection date. Builds read local data, without fetching from
another branch, and stop if the data is invalid.