๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Comparisons

primp vs curl_cffi: What the Engine Costs You

primp vs curl_cffi judged on install cost: wheel coverage, musl support, package size, cold start, source builds and release lag, with measured numbers.

S SparkProxy 13 10 min read
Share

primp vs curl_cffi is usually argued on fingerprints, and that argument is well covered: both libraries reproduce a browser ClientHello, and what TLS fingerprinting is explains what a server reads off the handshake. This post takes the axis that decides whether either one ever reaches production: primp is a Rust cdylib built on forked rustls and h2, curl_cffi is a thin cffi shim over curl-impersonate and BoringSSL, and those two engines behave completely differently at pip install time. Every number below was read from PyPI, the project repos, or measured on our own box on 2026-10-09.

What you are actually installing

Run both installs in a clean virtualenv and read the output rather than the README. On CPython 3.14.2, Windows Server 2022, pip 25.3:

$ pip install curl_cffi==0.16.3
Successfully installed certifi-2026.7.22 cffi-2.1.1 curl_cffi-0.16.3 pycparser-3.1

$ pip install primp==2.0.1
Successfully installed primp-2.0.1

curl_cffi brings four distributions. Its own pyproject.toml declares dependencies = ["cffi>=2.0.0", "certifi>=2024.2.2"], and cffi 2.1.1 in turn declares pycparser. primp declares dependencies = [], so the install graph is one node.

The native payload is shaped differently too. In the installed curl_cffi tree, curl_cffi/_wrapper.pyd is 39,936 bytes: that is the cffi shim and nothing else. The actual TLS stack sits beside it, vendored into the wheel by delvewheel as curl_cffi.libs/libcurl-impersonate-1172405423dc93b7aeed557f2a2ca325.dll. primp has no companion. Its entire engine is a single file, primp/primp.pyd, 15,222,272 bytes, because Rust statically links everything into one cdylib.

You can read the C stack off the installed package, which is a habit worth keeping:

import curl_cffi

print(curl_cffi.__version__)
print(curl_cffi.__curl_version__)
# 0.16.3
# libcurl/8.21.0-IMPERSONATE BoringSSL zlib/1.3.1 brotli/1.2.0 zstd/1.5.7
# WinIDN nghttp2/1.63.0 ngtcp2/1.20.0 nghttp3/1.15.0

That one string tells you what you deployed: BoringSSL for the TLS layer, nghttp2 for HTTP/2, and ngtcp2 plus nghttp3 for HTTP/3 over QUIC. primp exposes no equivalent, so you read its stack from the workspace manifest instead. Cargo.toml at the repo root lists four members: crates/primp, crates/primp-rustls/rustls, crates/primp-h2, and crates/primp-python. The core crate pulls rustls as package = "primp-rustls" pinned at 0.23.43 with the impersonate, aws-lc-rs, zlib and ml-dsa features, and h2 as package = "primp-h2" at 0.4.19. Two vendored forks, not upstream crates.

One consequence of that is easy to miss and cheap to verify. primp's core crate has an http3 feature gated on h3 0.0.8, h3-quinn 0.0.10 and quinn 0.11.1, but it is not in the default feature set, crates/primp-python/Cargo.toml requests only features = ["hickory-dns"], and [tool.maturin] features = [] adds nothing. Grep the shipped type stubs for http3, quic or h3 and you get no hits. So the primp you install from PyPI is an HTTP/1.1 and HTTP/2 client. curl_cffi's own README dates its HTTP/3 support to v0.11.4, with HTTP/3 proxy and fingerprint support from v0.15.0, and the ngtcp2 string above confirms the released wheel carries it. If your targets are on HTTP/3 and you care about matching a browser there, that difference is decided at build time and you cannot flip it with a keyword argument. Our HTTP/2 vs HTTP/1.1 for scraping post covers why the protocol version is visible to the server in the first place.

Wheel coverage, platform by platform

PyPI's JSON API is the primary source here, and the two release artifacts tell different stories. primp 2.0.1 publishes 31 files: 15 cp310-abi3 wheels, 15 cp314-cp314t free-threaded wheels, and a 1.07 MB sdist. curl_cffi 0.16.3 publishes 22 files: 11 cp310-abi3 wheels, two Android wheels tagged cp313 and cp314, eight cp314-cp314t wheels, and a 0.23 MB sdist.

Platform tagprimp 2.0.1curl_cffi 0.16.3
macosx_10_12 / 10_9 x86_64yesyes
macosx_11_0 arm64yesyes
manylinux_2_17 x86_64yesyes
manylinux_2_17 aarch64yesyes
manylinux_2_17 i686yesyes
manylinux armv7l2_172_28 / 2_31
manylinux riscv64no2_34 / 2_39
manylinux_2_17 s390xyesno
manylinux_2_28 ppc64leyesno
musllinux_1_2 x86_64yesyes
musllinux_1_2 aarch64yesyes
musllinux_1_2 i686yesno
musllinux_1_2 armv7lyesno
win_amd64yesyes
win_arm64yesyes
win32yesno
android arm64_v8anoyes
free-threaded cp314tall 15 platforms8 platforms

Neither project ships a PyPy wheel. Both tag their main wheels cp310-abi3, so a single file covers CPython 3.10 through 3.14, and both declare requires-python >= 3.10. On PyPy, an abi3 CPython wheel does not apply, so pip install falls through to the sdist for either package and you are building from source. primp's classifier list claims Implementation :: PyPy; the file list does not back that up with a wheel.

The free-threaded split is the sharpest difference in the table. primp mirrors its full matrix for 3.14t, all 15 platforms. curl_cffi's [tool.cibuildwheel] block lists eight cp314t targets: macOS x86_64 and arm64, Windows amd64 and arm64, manylinux x86_64 and aarch64, musllinux x86_64 and aarch64. If you are running free-threaded Python on i686, armv7l or riscv64, curl_cffi has no wheel for you and primp has two of the three.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Alpine, musl, and the 32-bit hole

Alpine is where engine choice stops being academic, because musl is a different libc and a manylinux wheel will not install on it. Both projects publish musllinux_1_2 wheels, so pip install works on Alpine x86_64 and aarch64 for either one. That covers most container fleets.

The hole opens on 32-bit musl. primp publishes musllinux_1_2_i686 and musllinux_1_2_armv7l. curl_cffi publishes neither, and the gap is not just a missing wheel: it also has no source-build path there. curl_cffi's libs.json is the lookup table its build script uses, and it holds 20 entries. The Linux rows are x86_64-gnu, x86_64-musl, i686-gnu, aarch64-gnu, aarch64-musl, riscv64-gnu, armv6l-gnueabihf, armv7l-gnueabihf and loongarch64-gnu. There is no i686-musl and no armv7l-musl row. scripts/build.py matches on system, machine, pointer size and libc, and when nothing matches it does this:

raise Exception(f"Unsupported arch: {uname}")

So on 32-bit Alpine, a pip install curl_cffi fails before it compiles anything. A 32-bit Raspberry Pi image or an old i686 Alpine base is a hard no for curl_cffi and a plain wheel install for primp.

In the other direction, curl_cffi's table carries rows primp has nothing for: FreeBSD amd64 and arm64, OpenBSD amd64, arm64 and riscv64, Android aarch64, and loongarch64. Those are prebuilt static archives, so a source install on FreeBSD is a download plus a link step rather than a full compile. primp would need a Rust cross-build you set up yourself.

A working Alpine Dockerfile for either client looks the same, and the useful part is the assertion:

FROM python:3.13-alpine
# musllinux_1_2 wheels exist for both on x86_64 and aarch64.
# --only-binary :all: turns a silent source build into a loud failure.
RUN pip install --only-binary :all: primp==2.0.1 curl_cffi==0.16.3
RUN python -c "import primp, curl_cffi; print(curl_cffi.__curl_version__)"

--only-binary :all: is the flag that matters. Without it, a base image whose arch has no wheel quietly starts a source build, and you find out when the layer has been running for twenty minutes or has died. If you are already running your scrapers in containers, using proxies with Docker covers the networking side of the same setup.

Package size on disk, and why it flips by OS

We downloaded the wheels and measured both the compressed file and the sum of the uncompressed members, plus the largest native object inside. Numbers are MB, from the wheels published for primp 2.0.1 and curl_cffi 0.16.3:

Targetprimp wheelprimp unpackedcurl_cffi wheelcurl_cffi unpacked
manylinux_2_17 x86_646.2814.9712.8637.14
musllinux_1_2 x86_646.5315.5012.6436.66
macosx_11_0 arm645.7412.372.666.59
win_amd646.0814.831.894.15

The ranking inverts across operating systems, and libs.json explains why. Every Windows row in it is link_type: dynamic; every other row is link_type: static. On Linux, libcurl-impersonate with BoringSSL, nghttp2, ngtcp2, nghttp3, brotli, zlib and zstd is linked straight into curl_cffi/_wrapper.abi3.so, which is 36.74 MB on manylinux x86_64. On macOS the same static archive produces a 6.19 MB object. On Windows the engine stays a separate 3.71 MB DLL that compresses well, and _wrapper.pyd itself is only 39,936 bytes. primp is boringly consistent at 12 to 15.5 MB everywhere, because [profile.release] sets lto = "fat", codegen-units = 1 and strip = "symbols", and that profile does not care which OS it runs on.

For a Lambda deployment package or a layer, the comparison you want is total unzipped bytes including dependencies. primp on manylinux_2_17_x86_64 is 14.97 MB and nothing else. curl_cffi is 37.14 MB plus cffi, pycparser and certifi, which adds roughly another 1.5 MB. Both sit far inside Lambda's 250 MB unzipped limit, so this is not a blocker; it is about 24 MB of layer you either pay for on every deploy or do not.

Measure it yourself rather than trusting a table you read somewhere:

import os
import zipfile


def wheel_cost(path):
    """Compressed size, uncompressed size, and the largest native member."""
    with zipfile.ZipFile(path) as zf:
        members = zf.infolist()
        unpacked = sum(m.file_size for m in members)
        native = [
            m for m in members
            if m.filename.endswith((".so", ".dylib", ".pyd", ".dll", ".a"))
        ]
        biggest = max(native, key=lambda m: m.file_size, default=None)
    return {
        "wheel_mb": round(os.path.getsize(path) / 1048576, 2),
        "unpacked_mb": round(unpacked / 1048576, 2),
        "largest_native": biggest.filename if biggest else None,
        "largest_native_mb": round(biggest.file_size / 1048576, 2) if biggest else 0,
    }

Cold start: what import costs you

Whatever the native object weighs, it has to be loaded, relocated and initialised before your first request. We measured that as the wall time of a fresh interpreter doing nothing but the import, 12 runs each, reporting the minimum and the median, against a pass baseline on the same interpreter. CPython 3.14.2, Windows Server 2022 Standard 10.0.20348, published wheels, 2026-10-09. Note that Windows is curl_cffi's lightest platform, a 40 KB shim plus a 3.71 MB DLL rather than the 36.74 MB static object it ships on Linux, so treat the gap below as the friendly case for it:

Commandmin (ms)median (ms)median net of baseline
`pass`22.923.50
`import primp`100.7103.880.3
`import curl_cffi`159.0166.9143.4
`from curl_cffi import requests`158.2161.3137.8
`primp.Client(impersonate="chrome_146")`101.5104.280.7
`requests.Session(impersonate="chrome136")`160.0169.1145.6

Read those as ours, not as the projects' numbers. They are one OS, one Python build, one machine, and they are import cost only, not request throughput. curl_cffi links a benchmark directory from its README and claims to be "Much faster than requests/httpx, on par with aiohttp/pycurl"; primp ships criterion benches in-repo and publishes no results, so there is no primp throughput figure to quote from the project itself, and we did not run a network benchmark to invent one.

Two things are worth taking away. Constructing a client costs essentially nothing on top of the import for either library: 104.2 against 103.8 for primp, 169.1 against 166.9 for curl_cffi. The expensive part is loading the native object, and it happens once per process. And the 63 ms median gap between the two is the same shape as the size gap. If you are invoking a Lambda per URL, you pay that difference on every cold start; if you are running a long-lived worker pool, you pay it once and it rounds to zero. The decision follows your execution model, not the library.

When no wheel exists: the two source builds

Sooner or later pip picks the sdist, and the two fallbacks are nothing alike.

primp's sdist is a maturin build. crates/primp-python/pyproject.toml sets requires = ["maturin>=1.5,<2.0"] with build-backend = "maturin", and both Cargo.toml files set rust-version = "1.89", so you need a Rust toolchain at 1.89 or newer. You also need a C or C++ compiler, which is less obvious: the forked rustls enables the aws-lc-rs provider, and aws-lc-rs's own README states plainly that "Consuming projects will need a C/C++ compiler to build." For non-FIPS builds it says CMake, bindgen and Go are never required, which removes three prerequisites people often assume. On Windows x86-64 the crate wants NASM, falling back to prebuilt NASM objects when NASM is absent and the prebuilt-nasm feature or AWS_LC_SYS_PREBUILT_NASM=1 applies.

Then there is the compile itself. The workspace builds a forked rustls, a forked h2, hickory-resolver, hyper, tokio and the rest, under lto = "fat" with codegen-units = 1. We tried it on a host with no system Rust install and 2.7 GB of free disk, and it got as far as compiling hickory-proto and primp-h2 before dying:

error: failed to write to `...\target\release\deps\rmetaEJu6IO\full.rmeta`:
There is not enough space on the disk. (os error 112)
error: could not compile `primp-h2` (lib) due to 1 previous error
๐Ÿ’ฅ maturin failed
  Caused by: Cargo build finished with "exit code: 101"

Treat that as a lower bound on scratch space, not a benchmark: our box ran out of room, so we never timed a finished build. The honest claim is that a primp source build is a full Rust release compile of a workspace that vendors two TLS-critical crates, and it needs a toolchain plus real disk.

curl_cffi's fallback is shorter but has more moving parts. The documented path in the README is git clone, make preprocess, pip install ., and make preprocess is where the prerequisites hide. The Makefile on main pins VERSION := 2.2.3 and CURL_VERSION := curl-8_22_0, downloads the curl source zip and the curl-impersonate tarball, applies curl.patch, runs autoreconf -fi, and copies the patched headers into include/. That needs curl, unzip, tar, patch and autotools on the build host, and it only produces headers.

The library itself arrives separately. scripts/build.py calls download_libcurl(), which fetches a prebuilt archive from GitHub releases at build time:

url = (
    f"https://github.com/lexiforest/curl-impersonate/releases/download/"
    f"v{__version__}/libcurl-impersonate-v{__version__}"
    f".{arch['arch']}-{sysname}.tar.gz"
)

So a curl_cffi source build needs outbound HTTPS to github.com. It retries three times with exponential backoff, and it skips the download when the expected object already exists. Two environment variables are the escape hatches, and they are not in the README:

# Put the prebuilt library somewhere persistent instead of a temp dir
export IMPERSONATE_BUILD_DIR=/opt/impersonate
# Force static or dynamic linking; "static" is rejected on Windows
export IMPERSONATE_LINK_TYPE=static
pip install --no-binary curl_cffi curl_cffi==0.16.3

Pre-populate IMPERSONATE_BUILD_DIR with the right libcurl-impersonate-v2.2.2.-.tar.gz contents and the build never reaches the network, which is how you get curl_cffi into an air-gapped builder. Note the version: we pulled the released 0.16.3 sdist and read scripts/build.py out of it, and it pins upstream 2.2.2, while main has already moved to 2.2.3. Pin the archive to the sdist you are building, not to the newest upstream tag.

The Windows source build installs, then fails to import

This one cost us an hour, so here it is in full. On Windows, pip install --no-binary curl_cffi curl_cffi==0.16.3 succeeds:

Created wheel for curl_cffi: filename=curl_cffi-0.16.3-cp310-abi3-win_amd64.whl
  size=126750
Successfully built curl_cffi
Successfully installed curl_cffi-0.16.3

126,750 bytes, against the 1.89 MB wheel PyPI publishes for the same version and platform. Then:

>>> import curl_cffi
ImportError: DLL load failed while importing _wrapper:
The specified module could not be found.

The missing module is not _wrapper, it is what _wrapper links against. Windows is the only dynamic link type in libs.json, so the build downloads libcurl-impersonate.dll rather than a static archive, and in a non-CI build with no IMPERSONATE_BUILD_DIR set, build.py puts it in a tempfile.TemporaryDirectory() that is deleted when the build ends. Nothing copies it into the package: build.py passes runtime_library_dirs=[] on Windows, and pyproject.toml declares package-data = { curl_cffi = ["libcurl.dll"] }, which is a different filename. The published wheels dodge all of this because cibuildwheel runs delvewheel repair, which is exactly why the installed wheel contains curl_cffi.libs/libcurl-impersonate-.dll.

Setting IMPERSONATE_BUILD_DIR keeps the file, and the import still fails, because that directory is not on the DLL search path. The fix is to put the DLL where the extension will look:

import shutil
import sysconfig
from pathlib import Path

# After: pip install --no-binary curl_cffi curl_cffi==0.16.3
# with IMPERSONATE_BUILD_DIR=C:\impersonate
src = Path(r"C:\impersonate\libcurl-impersonate.dll")
dst = Path(sysconfig.get_paths()["purelib"]) / "curl_cffi"
shutil.copy2(src, dst / src.name)

With the 3,885,056-byte DLL sitting next to curl_cffi/_wrapper.pyd, the import works and reports libcurl/8.21.0-IMPERSONATE BoringSSL. The general rule: on Windows, do not build curl_cffi from source unless you also run delvewheel repair on the result. On Linux and macOS the problem does not exist, because those platforms link statically and the engine ends up inside the .so.

primp cannot hit this class of bug at all. There is one native file and nothing for it to fail to find at load time, which is the single clearest operational advantage of the Rust build.

Release chains: how a new Chrome reaches your lockfile

The engines also decide how many repositories stand between a Chrome release and a version you can pin. Chrome stable dates below come from Google's own milestone schedule on chromiumdash.

primp owns its whole chain. The browser profiles live in its forked rustls, so a new profile is a commit in the same workspace that produces the wheel. Chrome 153 reached stable on 2026-09-08. primp's commit feat(imp): add Chrome 153 and Edge 152-153 profiles is dated 2026-09-12, release v2.0.1 was tagged the same day, and PyPI shows the files uploaded 2026-09-13. Five days from Chrome stable to pip install.

curl_cffi owns one link of a two-repo chain. The fingerprints live in curl-impersonate, which patches upstream curl and BoringSSL; curl_cffi then bumps the pinned version and rebuilds 21 wheels through cibuildwheel. Chrome 150 reached stable on 2026-06-30. curl-impersonate v2.0.0 on 2026-08-01 lists "Bump boringssl version to chrome 150", v2.1.0 on 2026-08-08 adds the chrome150 target, and the chrome150 value is present in the BrowserType enum of the installed curl_cffi 0.16.3, published 2026-09-02. Call it 64 days end to end, and chrome150 is still the newest Chrome profile in curl-impersonate's own supported-browsers table as of today.

The counterweight is cadence, and it runs the other way. Read release dates rather than adjectives:

Signal (read 2026-10-09)primpcurl_cffi
Latest stable on PyPI2.0.1, 2026-09-130.16.3, 2026-09-02
Newest prereleasenone0.16.4b1, 2026-09-20
Releases on PyPI, all time34102
First release0.5.3, 2024-07-270.1.5, 2022-08-23
Longest recent release gap0.15.0 2025-04-17 to 1.0.0 2026-02-13none over 6 weeks in 2026
Most recent commit on main2026-09-132026-10-09

Both are maintained; they are maintained differently. curl_cffi's main branch had a commit today, issue references are in the 800s, and it shipped ten releases including betas between 2026-07-18 and 2026-09-20. primp's most recent commit is 2026-09-13, 26 days ago, and its 20 most recent commits cover only 2026-08-26 to 2026-09-13, with 16 of them dated 2026-09-12 alone: the Chrome 153 profiles plus two fork syncs, chore(rustls): sync fork to upstream 0.23.43 and chore(h2): sync fork to upstream 0.4.19. Between 2025-04-17 and 2026-02-13 it published nothing at all.

Those fork syncs are the maintenance tax the Rust engine charges. Vendoring rustls and h2 means somebody has to rebase onto upstream security fixes, and the commit log shows that happening in batches tied to releases. curl_cffi's equivalent tax is a patch set against a specific curl release, currently curl 8.22.0 on main while the wheel you install reports libcurl 8.21.0. Neither is free. The difference is that curl_cffi's upstream is the curl project's release train, which has its own security cadence, while primp's upstream is two Rust crates and one maintainer's batching.

Failure modes you will meet in production

Engine choice also shapes what goes wrong and how legible it is. Every behaviour below was measured against the installed packages rather than inferred from docs.

A bad impersonate target fails at different moments. primp validates in the constructor:

import primp

primp.Client(impersonate="chrome_999")
# primp.BuilderError: Invalid impersonate: "chrome_999"
primp.Client(impersonate="chrome_120")
# primp.BuilderError: Invalid impersonate: "chrome_120"

curl_cffi validates on the first request instead:

from curl_cffi import requests

session = requests.Session(impersonate="chrome999")   # constructs fine
session.get("https://www.sparkproxy.io/")
# curl_cffi.requests.exceptions.ImpersonateError:
#   Impersonating chrome999 is not supported

That matters when the target comes from config or an environment variable. primp fails at startup, where a health check catches it; curl_cffi fails on the first live request, which in a queue worker means a poisoned job rather than a refused boot. Note the second primp example: chrome_120 is rejected too, because primp's oldest Chrome profile is chrome_144. curl_cffi's installed BrowserType holds 44 values, its Chrome entries running from chrome99 to chrome150 alongside Safari, Firefox, Edge and a tor145. If you need to look like an old build, only one of these two can do it.

Both clients read proxy environment variables by default, which bites in containers that set HTTPS_PROXY for something unrelated. We pointed HTTPS_PROXY at a closed port and made the same request with each:

primp     : ConnectError: error sending request for url (https://example.com/)
            > No connection could be made because the target machine
            actively refused it. (os error 10061)
curl_cffi : ConnectionError: Failed to perform, curl: (7) Failed to connect
            to example.com:443 over proxy 127.0.0.1 after 2001 ms

Same cause, very different diagnostics. curl's message names the proxy, the target, and the elapsed time; primp's surfaces a Windows socket error and does not mention the proxy at all. On the other hand primp gives you a typed exception tree to catch on: ConnectError, TimeoutError, DNSError, DNSTimeoutError, StatusError, RedirectError, BodyError, DecodeError and more, all under PrimpError. curl_cffi funnels most transport problems through CurlError and its requests-style subclasses, with the curl error code in the message. If your retry logic branches on exception type, primp's taxonomy is finer; if your on-call engineer greps logs, curl's strings say more. Our notes on retry and backoff strategies assume the second style, and the translation is mechanical.

A fail-fast startup probe is worth writing for whichever you pick, because it turns both of the above into a boot-time error:

def preflight(client_name, target):
    """Raise at startup if the engine or the profile is not really there."""
    if client_name == "primp":
        import primp

        client = primp.Client(impersonate=target)      # validates the profile
        return client.get("https://www.sparkproxy.io/").status_code

    import curl_cffi
    from curl_cffi import requests

    assert "BoringSSL" in curl_cffi.__curl_version__, curl_cffi.__curl_version__
    with requests.Session(impersonate=target) as session:
        # the first request is what validates the profile, so make one
        return session.get("https://www.sparkproxy.io/").status_code

primp vs curl_cffi by deployment target

Keyed on where the code runs, because that is what actually constrains the choice. "Wheel" means pip install resolves to a binary wheel with no toolchain on the host.

Deployment targetprimp 2.0.1curl_cffi 0.16.3Pick
AWS Lambda, x86_64 or arm64 (manylinux_2_17)Wheel, 14.97 MB unpacked, ~80 ms importWheel, 37.14 MB unpacked plus 3 deps, ~143 ms importprimp if cold starts dominate; curl_cffi if you need HTTP/3 or old profiles
Alpine container, x86_64 or aarch64musllinux_1_2 wheelmusllinux_1_2 wheelEither; size favours primp
Alpine on armv7l or i686musllinux_1_2 wheelNo wheel, and `libs.json` has no musl row for these, so the source build raises `Unsupported arch`primp, only option
ARM Mac (macosx_11_0 arm64)Wheel, 12.37 MB unpackedWheel, 6.59 MB unpackedEither; curl_cffi is the smaller install here
Windows amd64 or arm64Wheel, one `.pyd`Wheel with a `delvewheel`-vendored DLLEither from a wheel; primp if you must build from source
Windows 32-bit Python`win32` wheelNo wheel; `libs.json` has a 32-bit Windows row, so a source build is possibleprimp
Free-threaded CPython 3.14tcp314t wheels for all 15 platformscp314t for 8 platformsprimp outside the common four
PyPyNo wheel, sdist plus Rust 1.89 and a C compilerNo wheel, sdist plus a C toolchain and a prebuilt-library downloadNeither installs from a wheel; curl_cffi compiles less
riscv64No wheel`manylinux_2_34` wheelcurl_cffi
s390x, ppc64lemanylinux wheelsNo wheelprimp
Android arm64No wheelcp313 and cp314 wheelscurl_cffi
FreeBSD, OpenBSDNo wheel, no prebuilt archiveNo wheel, but a prebuilt static archive exists in `libs.json`curl_cffi
Air-gapped build hostRust toolchain plus a vendored cargo registryPre-populate `IMPERSONATE_BUILD_DIR`, no network neededcurl_cffi, with the env var
HTTP/3 targetNot in the PyPI wheelngtcp2 and nghttp3 in the wheelcurl_cffi

Two rows carry most of the weight in practice. Lambda is where the size and import numbers earn their keep, and 32-bit musl is where one of the two simply does not install.

Pins and lockfiles that survive a rebuild

Both packages are native, so an exact version pin is not enough on its own. Three habits keep builds reproducible.

Pin the version and forbid source builds, so a missing wheel is an error instead of a surprise compile:

# requirements.txt
primp==2.0.1
curl_cffi==0.16.3
# install with:
#   pip install --only-binary :all: -r requirements.txt

Build the wheel set for the target platform, not the build machine. If you build on an ARM Mac and deploy to x86_64 Lambda, resolve explicitly:

pip download --only-binary :all: \
  --platform manylinux_2_17_x86_64 \
  --implementation cp --abi abi3 --python-version 3.12 \
  -d ./vendor primp==2.0.1 curl_cffi==0.16.3

Do not rely on --abi abi3 for the free-threaded case: the 3.14t wheels are tagged cp314-cp314t, not abi3, so a free-threaded target needs --abi cp314t and the matching --python-version 3.14.

Finally, check what you got rather than what you asked for. Both projects publish prereleases, curl_cffi frequently, and --pre in a CI config has a habit of outliving the reason it was added:

import importlib.metadata as md

for dist in ("primp", "curl_cffi", "cffi", "certifi"):
    try:
        print(f"{dist}=={md.version(dist)}")
    except md.PackageNotFoundError:
        print(f"{dist}: not installed")

Where the proxy layer changes the answer

Both engines solve the same narrow problem: making the handshake and the HTTP/2 frames look like a browser's. Neither changes the IP the handshake comes from, and on the sites that run real anti-bot systems, a clean fingerprint from a flagged datacenter range still gets a challenge. Identifying which anti-bot a site uses is the first step; the practical point is that the client and the exit IP are separate variables and you need both.

primp takes a single proxy string on the client, with no per-request override and no proxies dict:

import primp

PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000"

client = primp.Client(
    impersonate="chrome_146",
    impersonate_os="windows",
    proxy=PROXY,
    timeout=30.0,
)
resp = client.get("https://www.sparkproxy.io/")
print(resp.status_code, len(resp.text))

curl_cffi accepts the requests-style mapping and lets you swap it per call, which is the better fit for rotating on every request:

from curl_cffi import requests

PROXIES = {
    "http": "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000",
    "https": "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000",
}

with requests.Session(impersonate="chrome146") as session:
    resp = session.get("https://www.sparkproxy.io/", proxies=PROXIES, timeout=30)
    print(resp.status_code, resp.http_version)

SparkProxy's residential pools start at $75/month as of 2026-10-09, in USD. Plans are packs rather than per-GB, unlimited bandwidth is included, so the billing unit is the plan and its concurrency rather than the traffic a scrape consumes. The pool is 1M+ premium IPs across 80+ countries, with random rotation or 5-minute auto-rotation, HTTP, HTTPS and SOCKS5, IP whitelist or user:pass auth, and 1 Gbps port speed. There is a 24-hour free trial with no credit card, and term discounts of 5% at 3 months, 10% at 6 and 15% at 12. How to rotate proxies in Python covers the rotation patterns both clients support.

When the page needs JavaScript, no TLS client gets you there and the escalation is a rendering service or a browser, which is a different post. SparkProxy's Scraping API is the cheaper half of that escalation, and it is also useful for a reason specific to this comparison: it has no native dependency at all, so it is the fallback that works on the platform where neither wheel exists.

import base64

import primp

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

client = primp.Client(impersonate="chrome_146", timeout=60.0)

# render_js defaults to TRUE and costs 5 credits; false is a 1-credit plain fetch.
# json_response=true is the only thing that returns the envelope, and the body
# inside it is base64, so decode before matching on anything.
resp = client.get(
    API,
    headers=HEADERS,
    params={
        "url": "https://www.sparkproxy.io/",
        "render_js": "false",
        "proxy_type": "premium",
        "json_response": "true",
    },
)
envelope = resp.json()
html = base64.b64decode(envelope["body"]).decode("utf-8", "replace")

print(envelope["status_code"], envelope["duration_ms"], envelope["credits_used"])
print("title tag present:", "<title" in html)

Three parameter notes that are easy to get wrong. format is content conversion only, so html, md, mdx, json, screenshot or pdf, and format=json does not give you the envelope; only json_response=true does. country_code adds 5 credits and js_scenario adds 5 and requires render_js=true with at most 50 steps. extract_rules replaces the response with an extracted object, so never stack it with json_response. Batch mode needs render_js=false and the whole batch costs 1 credit. New accounts get 1,000 free credits; the credit pricing breakdown has the full arithmetic.

The same call with curl_cffi, for the comparison:

import base64

from curl_cffi import requests

API = "https://scrape.sparkproxy.io/api/v1"
PARAMS = {
    "url": "https://www.sparkproxy.io/",
    "render_js": "false",
    "proxy_type": "premium",
    "json_response": "true",
}

with requests.Session(impersonate="chrome146") as session:
    resp = session.get(API, headers={"X-API-Key": "YOUR_API_KEY"},
                       params=PARAMS, timeout=60)
    envelope = resp.json()

body = base64.b64decode(envelope["body"]).decode("utf-8", "replace")
print(envelope["credits_used"], len(body))

If you want the fingerprint detail behind these snippets rather than the packaging, web scraping with curl_cffi is the hands-on guide, and requests vs httpx covers what you give up by leaving the pure-Python clients behind.

Frequently asked questions

FAQ

primp 2.0.1 publishes 30 wheels and curl_cffi 0.16.3 publishes 21, but the gaps do not overlap. primp covers s390x, ppc64le, 32-bit Windows and 32-bit musl; curl_cffi covers riscv64, Android arm64, FreeBSD and OpenBSD source builds. Check your exact platform tag rather than the totals.

Both, on x86_64 and aarch64, via musllinux_1_2 wheels. On 32-bit musl, only primp: it ships musllinux_1_2_i686 and musllinux_1_2_armv7l, while curl_cffi has no wheel and no matching row in libs.json, so its source build raises Unsupported arch before compiling.

On Windows, libs.json marks the link type dynamic, so the build downloads libcurl-impersonate.dll into a temporary directory and nothing copies it into the package. The published wheels vendor it via delvewheel repair; a local build does not. Copy the DLL next to curl_cffi/_wrapper.pyd, or set IMPERSONATE_BUILD_DIR and repair the wheel yourself.

In our measurement, yes: 80.3 ms against 143.4 ms median, net of a 23.5 ms baseline, on CPython 3.14.2 and Windows Server 2022 with the published wheels. That is import cost on one machine, not request throughput, and neither project publishes a throughput figure we could quote.

No. http3 is an optional cargo feature in primp's core crate, the Python crate requests only hickory-dns, maturin adds no features, and the shipped type stubs contain no HTTP/3 symbols. curl_cffi's released wheel reports ngtcp2 and nghttp3 in __curl_version__, and its README dates HTTP/3 support to v0.11.4.

Neither publishes a PyPy wheel, so PyPy means a source build either way. For 32-bit Windows, primp ships a win32 wheel; curl_cffi does not, though libs.json has a 32-bit Windows row so a source build is possible with a compiler.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's residential proxies, datacenter proxies and Scraping API, and spends a lot of time on the unglamorous half of scraping infrastructure: which wheels exist, what breaks in a container build, and why a client that works on a laptop fails in a Lambda. The measurements in this post were taken on 2026-10-09 against published artifacts, and every version number, release date and build setting was read from the projects' own PyPI metadata, repositories and manifests on that date.

Keep reading

Related articles

Insomniac Browser Alternatives, Scored by Layer

Insomniac Browser alternatives scored by which identity layer each one really covers, with prices verified in October 2026 and a measured three-profile test.

SparkProxyยทComparisons