Python Web Scraping

curl_cffi vs requests in Python

By the Scrappey Research Team

curl_cffi vs requests in Python — conceptual illustration
On this page

curl_cffi and requests are both Python HTTP clients, but curl_cffi can impersonate a real browser's TLS and HTTP/2 fingerprint while requests cannot, which is the main reason to choose it for sites that fingerprint clients. The requests library sends a static OpenSSL handshake that produces a recognizably "Python" JA3/JA4 signature, so anti-bot systems can flag the connection before your headers are even read. curl_cffi is built on the curl-impersonate fork and replays the exact TLS and HTTP/2 settings of browsers like Chrome and Safari, while keeping an API that is nearly a drop-in replacement for requests. For plain APIs and unguarded pages, requests remains simpler and lighter; for fingerprint-aware targets, curl_cffi is the more reliable starting point.

Quick facts

requests fingerprintStatic Python/OpenSSL JA3, easily flagged
curl_cffi enginecurl-impersonate (browser TLS + HTTP/2)
API compatibilityNear drop-in: Session, get(), post()
Key argumentimpersonate="chrome" / "safari"
Protocolscurl_cffi adds HTTP/2, HTTP/3, WebSocket

Why requests has a Python-shaped TLS fingerprint

The requests library cannot control how its TLS handshake looks, so every connection carries a fingerprint that signals "Python automation" rather than a browser. Under the hood, requests uses urllib3, which uses Python's built-in ssl module backed by OpenSSL. The order of cipher suites, the set of TLS extensions, the supported elliptic curves, and the ALPN values are all fixed by that stack and differ from what Chrome, Firefox, or Safari send.

Anti-bot systems hash these handshake parameters into a compact label. JA3 hashes the TLS 1.2-era ClientHello fields; the newer JA4 adds details like the ALPN protocol and whether the connection negotiated HTTP/2. Because every requests install on a given Python version produces the same handshake, that hash has been seen on millions of bot connections and is trivial to match against a deny list.

  • Spoofing the User-Agent header does not help: the header says "Chrome" but the TLS layer underneath still says "Python," and the mismatch itself is a signal.
  • HTTP/2 frame settings (header order, window sizes, pseudo-header ordering) add a second fingerprintable layer that requests does not emit, because requests speaks only HTTP/1.1.

None of this matters for an endpoint that does not inspect TLS. It becomes the deciding factor only when a site runs a fingerprint check, which is exactly the gap curl_cffi is designed to fill.

How curl_cffi impersonates a browser, and how close the API is

curl_cffi binds Python to curl-impersonate, a patched build of libcurl that swaps in the same TLS settings real browsers use, so the JA3/JA4 and HTTP/2 fingerprints match a genuine browser instead of a generic client. You select the target with a single impersonate= argument: pass a version like "chrome124" or "safari18_0", or a moving alias like "chrome", "safari", or "firefox" to track the latest profile the library ships. New version labels are added when a browser's fingerprint actually changes, so a skipped number can usually be covered by the nearest earlier profile.

The migration cost is low because curl_cffi deliberately mirrors the requests API. The same call shapes work with minimal edits:

  • curl_cffi.requests.get(url, impersonate="chrome") mirrors requests.get(url).
  • Session() objects persist cookies and connections, like requests' Session.
  • Familiar keyword arguments carry over: params, headers, json, data, proxies, timeout, and verify.

It also goes beyond requests where the protocol matters: it supports HTTP/2 (and newer builds add HTTP/3), WebSocket connections, and an asyncio interface for concurrency. The practical differences are that you must pick an impersonate target to get the browser fingerprint, and that curl_cffi installs a compiled wheel rather than being pure Python.

When to reach for each one (and where they stop)

Use requests when the target does not fingerprint clients, and use curl_cffi when TLS or HTTP/2 fingerprinting is what gets you a 403. Each tool genuinely wins in its own lane.

requests is the better pick when you are calling a documented JSON API, an internal service, or a static page with no anti-bot layer. It is pure Python, has no compiled dependency, ships in nearly every environment, and has the largest ecosystem of adapters, auth helpers, and tutorials. For that work, adding curl_cffi buys you nothing and adds a binary dependency.

curl_cffi is the better pick when the same request works in a real browser but returns a block from requests, when responses differ depending on the client, or when you need HTTP/2, HTTP/3, or WebSocket support that requests does not provide. Because the API overlaps so heavily, swapping in curl_cffi is often a small diff.

It is worth being clear about the limit: curl_cffi fixes the transport fingerprint, not behavior above it. It does not run JavaScript, solve challenges, render pages, or manage proxy rotation. Sites that gate on browser execution still need a real browser engine such as Playwright or Selenium. When you would rather not assemble TLS impersonation, headless browsers, proxy pools, and retry logic yourself, a managed web-data API like Scrappey bundles those concerns behind a single call, while curl_cffi remains an excellent self-hosted choice when transport fingerprinting is the only obstacle.

Code example

python
# pip install curl_cffi requests
import requests
from curl_cffi import requests as cffi

URL = "https://tls.browserleaks.com/json"  # echoes back the TLS fingerprint it sees

# 1) Plain requests: static Python/OpenSSL handshake -> recognizable JA3
r1 = requests.get(URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
print("requests JA3:", r1.json().get("ja3_hash"))

# 2) curl_cffi: same call shape, plus impersonate= for a browser fingerprint
#    'chrome'/'safari'/'firefox' track the latest profile; or pin e.g. 'chrome124'
r2 = cffi.get(URL, impersonate="chrome", timeout=20)
print("curl_cffi JA3:", r2.json().get("ja3_hash"))

# Sessions work like requests.Session (persists cookies + connection reuse)
with cffi.Session(impersonate="safari") as s:
    s.get("https://example.com/login")          # sets cookies
    resp = s.post(
        "https://example.com/api/search",
        json={"q": "laptops", "page": 1},
        headers={"Accept": "application/json"},
        timeout=20,
    )
    resp.raise_for_status()
    print(resp.status_code, resp.json())

# The two JA3 hashes above should differ: that delta is exactly what a
# fingerprint-aware anti-bot layer keys on.

Related terms

Concept map

How curl_cffi vs requests in Python connects

The terms most directly tied to this one. Hover a node to see its neighbours, click to preview, drag to rearrange.

0 terms · 0 connections
You are here · Python Web Scraping
Building map…

Frequently asked questions

Is curl_cffi a drop-in replacement for requests?

It is very close to one. curl_cffi deliberately mirrors the requests API, including Session objects, get() and post() methods, and common keyword arguments like params, headers, json, and proxies, so most scripts migrate with small edits. The main differences are that you add an impersonate argument to get the browser fingerprint, and that curl_cffi installs a compiled wheel rather than being pure Python, so a few advanced requests features and third-party adapters may not map one to one.

Does setting a browser User-Agent in requests fix the fingerprint problem?

No. The User-Agent is just an HTTP header sent inside the connection, while JA3 and JA4 fingerprints are computed from the TLS handshake that happens before any headers are read. requests cannot change that handshake, so a Chrome User-Agent on a Python TLS fingerprint actually creates a mismatch that can make the request look more suspicious, not less.

What does the impersonate argument accept?

It accepts either a specific browser profile such as 'chrome124' or 'safari18_0', or a moving alias such as 'chrome', 'safari', or 'firefox' that tracks the most recent profile the installed version ships. New version labels are added only when a browser's fingerprint actually changes, so if a particular number is missing you can usually impersonate it with the closest earlier profile. Check your installed curl_cffi version's documentation for the exact list of supported targets.

When should I still use plain requests instead of curl_cffi?

Use requests when the target does not fingerprint clients, such as documented JSON APIs, internal services, and static pages with no anti-bot layer. In those cases requests is simpler, fully pure Python with no compiled dependency, and backed by a larger ecosystem of helpers and tutorials. Reach for curl_cffi specifically when a request that works in a real browser is blocked from requests, or when you need HTTP/2, HTTP/3, or WebSocket support.

Last updated: 2026-06-16 · Facts last verified: 2026-06-16