The first time a site 403s you for no reason is confusing. Your headers are perfect, your User-Agent says Chrome, the page loads fine in a browser, and requests.get() still comes back with a Cloudflare interstitial.
The server never read your User-Agent. It fingerprinted your TLS handshake, saw Python's OpenSSL cipher order, and blocked you before the HTTP request existed. That's the problem curl_cffi solves, and it solves it with one keyword argument.
This guide covers how to use curl_cffi from pip install through sessions, proxies, async concurrency, and the errors you'll hit along the way. You need Python 3.10+ and nothing else. Every solution here runs on your own machine, with no scraping API involved.
If you want the theory first, read what a TLS fingerprint is and come back. If you want working code, keep going.
What is curl_cffi?
curl_cffi is a Python HTTP client that impersonates real browser TLS and HTTP/2 fingerprints. It binds to curl-impersonate, a patched libcurl, through cffi, and exposes a requests-style API. Pass impersonate="chrome" and your request negotiates its handshake the way Chrome does, so fingerprint-based bot detection sees a browser. Use it when requests gets 403s that a browser doesn't.
In practice, that means curl_cffi can:
- Match the JA3/JA4 hash and HTTP/2 SETTINGS frame of Chrome, Safari, Firefox, Edge, and Tor presets
- Speak HTTP/2 and HTTP/3, which
requestscannot - Run sync, async (asyncio), and WebSocket workloads from one package
- Retry natively with backoff and jitter, no
urllib3.Retrydance - Rotate proxies per request inside an async session
The project is MIT licensed, maintained by lexiforest, and ships prebuilt wheels for Linux, macOS, and Windows. There's no compiler step. The GitHub repo is the source of truth; the docs are current and worth bookmarking.
One naming quirk trips up everyone once: the PyPI package is curl-cffi (hyphen), the import is curl_cffi (underscore). Pip normalizes both spellings, so either works at install time. The import does not forgive you.
curl_cffi vs requests vs httpx vs aiohttp
curl_cffi isn't the only HTTP client in Python, and for plain API work it's overkill. It earns its spot when the target checks fingerprints.
| Feature | curl_cffi | requests | httpx | aiohttp |
|---|---|---|---|---|
| Browser TLS/HTTP2 fingerprints | Yes | No | No | No |
| HTTP/2 | Yes | No | Yes (extra) | No |
| HTTP/3 | Yes | No | No | No |
| Sync | Yes | Yes | Yes | No |
| Async | Yes | No | Yes | Yes |
| WebSocket | Yes | No | No | Yes |
| Native retry | Yes | No | No | No |
| Pure Python | No (C via cffi) | Yes | Yes | Mostly |
Choose curl_cffi when the site returns 403 to requests but 200 to a browser, when you need HTTP/2 without extras, or when you want proxy rotation baked into an async session.
Stick with requests or httpx for internal APIs, well-behaved public endpoints, or anywhere the fingerprint doesn't matter. The C dependency makes curl_cffi slightly heavier to package (PyInstaller needs a flag; see troubleshooting), and that cost isn't worth paying for api.github.com.
The repo's benchmark folder puts it on par with aiohttp and pycurl and ahead of requests/httpx. Run it yourself before quoting numbers; results depend heavily on your target and network.
How to install curl_cffi
Python 3.10 is the floor since v0.14. Python 3.9 is end-of-life and unsupported. Check first:
python --version # needs 3.10 or newer
Then install into a virtual environment. This matters more than usual here because curl_cffi bundles its own libcurl-impersonate, and mixing it with a system curl or an old pycurl in the same environment invites confusion.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install curl_cffi --upgrade
macOS users can also brew install lexiforest/tap/curl-cffi if they want the CLI system-wide. Verify with a one-liner:
python -c "import curl_cffi; print(curl_cffi.__version__)"
Since v0.15 the package ships a CLI called curl-cffi. It's the fastest way to sanity-check an install and to see what a target sees:
curl-cffi get https://tls.browserleaks.com/json --impersonate chrome
You'll get JSON back with a ja3n_hash, ja4, and akamai_hash. Compare those to a real Chrome tab on the same site. If they match, the install works and the impersonation is sound.
Two more CLI commands you'll use: curl-cffi list shows every fingerprint preset on your machine, and curl-cffi update pulls newer Chrome, Safari, and Firefox presets without upgrading the package. That second one is new, and most tutorials don't mention it.
curl_cffi basic concepts
Four ideas carry the whole library.
Impersonation targets
A target is a string like chrome146, safari260, or firefox147 that selects a complete browser profile: cipher suites in browser order, TLS extension order, ALPN offer, HTTP/2 SETTINGS, and the matching default headers. The version-less aliases chrome, safari, and safari_ios resolve to the newest preset in your installed version.
The full preset table lives in the curl-impersonate docs. Firefox has been supported since the firefox133 preset; older guides that say otherwise are out of date.
Default headers
Setting impersonate= also injects that browser's headers (User-Agent, Accept, sec-ch-ua, and so on). This is a feature, and it's also the source of the most common self-inflicted wound in curl_cffi code. More on that in the header trap section.
Session vs one-shot calls
curl_cffi.get() works like requests.get(): new connection, no cookie jar. curl_cffi.Session() reuses connections and persists cookies. The maintainers say to always use a session where possible, and they're right; a fresh handshake per request is slower and looks less like a browser to anti-bot systems.
Sync, async, and the low-level curl API
The requests-like layer is what you'll use 95% of the time. AsyncSession gives you the same API under asyncio. Beneath both sits a raw Curl object with setopt() for anything libcurl can do. You won't need it in this guide, but it's there when a weird option comes up.
Your first curl_cffi request
Suppose you're scraping a retail site that blocks requests outright. The plan: fetch with impersonation, confirm the status, and parse the title with BeautifulSoup.
Step 1: Install the parser
pip install beautifulsoup4
Step 2: Make an impersonated request
Save this as first_request.py. The only line that differs from a requests script is the impersonate argument.
# first_request.py
import curl_cffi
from bs4 import BeautifulSoup
URL = "https://tls.browserleaks.com/json"
# impersonate="chrome" picks the newest Chrome preset you have installed
r = curl_cffi.get(URL, impersonate="chrome", timeout=20)
r.raise_for_status() # raises curl_cffi HTTPError on 4xx/5xx
data = r.json()
print("Status:", r.status_code)
print("JA3N:", data.get("ja3n_hash"))
print("Akamai (HTTP/2):", data.get("akamai_hash"))
raise_for_status() behaves like the one in requests, and r.json(), r.text, r.content, and r.headers are all where you'd expect them.
Step 3: Run it
python first_request.py
You should see a 200 and two hashes. Now change impersonate="chrome" to impersonate="safari" and run it again; the hashes change, because you're now presenting Safari's handshake. That's the whole mechanism in two runs.
Step 4: Point it at HTML
Swap the URL for a real page and parse it. This example reads a public product listing and prints the <title>; replace the URL with your own target.
import curl_cffi
from bs4 import BeautifulSoup
r = curl_cffi.get(
"https://books.toscrape.com/", # a public practice site
impersonate="chrome",
timeout=20,
)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.get_text(strip=True))
for h3 in soup.select("article.product_pod h3 a")[:5]:
print("-", h3["title"])
If a site that 403'd requests now returns 200, you were being TLS-fingerprinted. If it still 403s, the block is somewhere else (IP reputation, a JavaScript challenge, or missing cookies), and no amount of fingerprint tweaking will fix it. The 403 Forbidden guide walks through how to tell those apart.
How to use curl_cffi sessions and cookies
Most real scrapes make several requests against one site: a landing page that sets cookies, then a search, then detail pages. A Session keeps the cookies and reuses the TCP/TLS connection across all of them.
You can set impersonate once on the session instead of on every call:
from curl_cffi import Session
with Session(impersonate="chrome", timeout=20) as s:
s.get("https://httpbin.org/cookies/set/session_id/abc123")
print(s.cookies) # <Cookies[<Cookie session_id=abc123 ...>]>
r = s.get("https://httpbin.org/cookies")
print(r.json()) # {'cookies': {'session_id': 'abc123'}}
Two details worth knowing. response.cookies only holds cookies from that one response, and after a redirect chain it may be incomplete; read session.cookies instead. And if you want connection reuse without cookie persistence (some login flows want this), create the session with discard_cookies=True.
You can also seed a session with cookies you captured elsewhere, which is how people carry a solved cf_clearance from a browser into curl_cffi. The cf_clearance guide covers that flow; the short version is that the cookie is tied to the fingerprint and IP that earned it, so present the same impersonate target and the same exit IP.
How to use curl_cffi with proxies
Fingerprints are half the fight. The other half is the IP. A perfect Chrome handshake from a datacenter range still gets rate-limited on aggressive sites, so at scale you'll route through a proxy pool you control.
curl_cffi takes the same proxies dict as requests, and supports HTTP, HTTPS, and SOCKS5 schemes:
from curl_cffi import Session
proxy = "http://user:[email protected]:8080"
with Session(impersonate="chrome") as s:
r = s.get(
"https://httpbin.org/ip",
proxies={"http": proxy, "https": proxy},
)
print(r.json()) # {'origin': '<proxy exit IP>'}
For SOCKS5, use socks5h:// so DNS resolves through the proxy rather than leaking from your machine.
For rotation, keep a plain list and cycle through it. No framework needed:
import itertools
from curl_cffi import Session
PROXIES = [
"http://user:[email protected]:8080",
"http://user:[email protected]:8080",
"http://user:[email protected]:8080",
]
pool = itertools.cycle(PROXIES)
with Session(impersonate="chrome", timeout=15) as s:
for url in ["https://httpbin.org/ip"] * 3:
p = next(pool) # round-robin
r = s.get(url, proxies={"http": p, "https": p})
print(r.json()["origin"])
Pin one proxy per session when the site uses cookies; a session whose IP jumps between cities every request looks nothing like a person. Rotate per request only for independent one-shot fetches.
The proxies for web scraping guide goes deeper on when to use each pattern. If you don't run your own pool, Roundproxies residential and ISP proxies plug into this exact proxies= dict with no adapter.
Async scraping with AsyncSession
AsyncSession mirrors Session under asyncio and is where curl_cffi pulls ahead of requests. This example fetches a batch of pages with a concurrency cap and per-request proxy rotation.
import asyncio, itertools
from curl_cffi import AsyncSession
URLS = [f"https://books.toscrape.com/catalogue/page-{i}.html" for i in range(1, 11)]
PROXIES = itertools.cycle(["http://user:[email protected]:8080"]) # add more
SEM = asyncio.Semaphore(5) # never more than 5 in flight
async def fetch(s, url):
p = next(PROXIES)
async with SEM:
r = await s.get(url, proxies={"http": p, "https": p})
return url, r.status_code, len(r.text)
async def main():
async with AsyncSession(impersonate="chrome", timeout=20) as s:
results = await asyncio.gather(*(fetch(s, u) for u in URLS))
for url, code, size in results:
print(code, size, url)
asyncio.run(main())
Two things to notice. You must use a session in async mode; there's no await curl_cffi.get(). And the semaphore is doing real work: without it, gather fires all ten at once, which is fine for ten and a problem for ten thousand.
If you're on Windows and see event-loop errors, set asyncio.WindowsSelectorEventLoopPolicy() before asyncio.run(). It's a general asyncio quirk, not a curl_cffi one.
Retries, timeouts, and streaming
curl_cffi has native retries, which most guides skip because they were written before the feature existed. Pass an integer for a simple count or a RetryStrategy for backoff:
from curl_cffi import Session, RetryStrategy
strategy = RetryStrategy(
count=3,
delay=0.5, # seconds before first retry
jitter=0.2, # random +/- to avoid thundering herds
backoff="exponential", # 0.5, 1.0, 2.0 ...
)
with Session(impersonate="chrome", retry=strategy, timeout=15) as s:
r = s.get("https://httpbin.org/status/503")
print(r.status_code)
Set timeout= on every session. curl's default is generous enough that a stalled proxy can hang a worker for a long time. Fifteen to thirty seconds is a sane starting point for HTML pages.
For large downloads, don't hold the body in memory. stream=True works for compatibility, but the docs warn that the response starts buffering immediately, so if you're slow to consume it you can hit an out-of-memory error. The native content_callback avoids that:
import curl_cffi
def write_chunk(chunk: bytes, f=open("big.bin", "wb")):
f.write(chunk)
curl_cffi.get(
"https://httpbin.org/bytes/1048576",
impersonate="chrome",
content_callback=write_chunk, # called per chunk as it arrives
)
One more API difference from requests: file uploads use multipart=curl_cffi.CurlMime() instead of files=. The files= keyword isn't supported and fails loudly. Posting JSON (json=) and forms (data=) work exactly as in requests; raw bytes should go through content=.
The header trap: don't undo your own fingerprint
The mistake I see most in curl_cffi code from people migrating off requests is this:
# Don't do this
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0"}
r = curl_cffi.get(url, impersonate="chrome", headers=headers)
impersonate="chrome" already set a User-Agent, and it set one that matches the TLS profile: same Chrome major version, same platform as the preset. The override above now claims Chrome 120 on Windows over a handshake that belongs to Chrome 146 on macOS. That contradiction is exactly what a fingerprinting vendor scores.
Three rules keep you out of it:
- Let curl_cffi set the User-Agent and the
sec-ch-ua*family. Only add headers the browser would send on top, likeRefererorAccept-Languagefor a specific locale. - If you must control every header, pass
default_headers=Falseand supply a complete, internally consistent set yourself. Half-and-half is the worst option. - Pin the preset.
impersonate="chrome"moves forward as you upgrade, which is usually good, but a stale pin (chrome110in 2026) is itself a signal. Checkcurl-cffi listand pick something recent.
If you need a specific UA with a matching profile, the supported path is get_fingerprint(): fetch the preset, edit its headers, pass the object back to impersonate=. That keeps the TLS side and the header side from the same browser.
import curl_cffi
fp = curl_cffi.get_fingerprint("chrome146") # returns an editable profile
fp.headers["Accept-Language"] = "de-DE,de;q=0.9"
r = curl_cffi.get("https://httpbin.org/headers", impersonate=fp)
print(r.json()["headers"]["Accept-Language"])
Debugging this is easiest from the terminal: curl-cffi get https://httpbin.org/headers --impersonate chrome shows you the exact header set the preset sends, so you can see what you're overriding before you override it.
Common curl_cffi errors and how to fix them
"ModuleNotFoundError: No module named 'curl_cffi'"
What it means: Either the package isn't installed in the active environment, or you typed the import with a hyphen.
How to fix it: pip install curl_cffi --upgrade inside the venv you're running from, and make sure the import line reads import curl_cffi, underscore. Check which python matches the venv.
"curl_cffi.requests.exceptions.RequestsError: Failed to perform, curl: (35)"
What it means: Code 35 is a TLS handshake failure. Common causes: an HTTPS proxy that doesn't support the negotiated protocol, a corporate MITM certificate, or a proxy URL with the wrong scheme.
How to fix it: Try the request without the proxy first. If it works, the proxy is the problem; switch https:// to http:// in the proxy URL (most forward proxies expect that) or use socks5h://. For a MITM cert, set verify="/path/to/ca.pem", or verify=False only while debugging.
"curl: (28) Operation timed out" / "RequestsError: ... 28"
What it means: The server or proxy stopped responding before timeout elapsed. On heavily protected sites this is often a soft block: the WAF holds the connection open rather than sending a 403.
How to fix it: Lower the timeout, wrap the call in a RetryStrategy, and rotate the proxy on retry. If a specific IP times out repeatedly while others succeed, retire it from the pool.
"TypeError: ... got an unexpected keyword argument 'files'"
What it means: You're using the requests upload API. curl_cffi doesn't implement files=.
How to fix it: Build a CurlMime object and pass it as multipart=:
import curl_cffi
mp = curl_cffi.CurlMime()
mp.addpart(name="file", filename="report.pdf",
content_type="application/pdf", local_path="./report.pdf")
r = curl_cffi.post("https://httpbin.org/post", multipart=mp)
"UnrewindableBodyError"
What it means: A retry or redirect needed to resend a request body that came from a one-shot iterator (a generator passed via content=).
How to fix it: Pass a seekable file object or bytes instead of a generator when retries or redirects are possible; curl_cffi rewinds seekable bodies automatically.
PyInstaller build runs but crashes on import
What it means: PyInstaller didn't collect the bundled libcurl-impersonate shared library or the cffi backend.
How to fix it:
pyinstaller -F app.py --hidden-import=_cffi_backend --collect-all curl_cffi
General debugging tips
- Reproduce with the CLI before touching Python:
curl-cffi get URL --impersonate chrome -vshows the handshake and headers. - Compare hashes at
tls.browserleaks.com/jsonbetween curl_cffi and a real browser. If they differ, your preset is stale; runcurl-cffi update. - When a site blocks curl_cffi but not a browser, log the response body. Cloudflare, Akamai, and DataDome each leave recognizable markers, and knowing which one you're facing changes the fix entirely.
curl_cffi best practices for scrapers
Once requests are going through, learning how to use curl_cffi well is mostly about keeping your traffic consistent. The impersonate option gives you a browser-grade TLS and HTTP/2 fingerprint. Connection handling, IP choice, preset freshness, and request pacing are still up to you, and a mismatch in any one of them can undo a perfect Client Hello. These habits cover the mistakes that most often get a working scraper blocked.
1. Always use a session
Connection reuse is faster and closer to how a browser behaves. A browser opens a connection to a host once and sends many requests over it. A scraper that performs a fresh handshake for every URL looks different on the wire and pays the TLS setup cost on each request.
Use one-shot curl_cffi.get() for quick checks, such as testing whether a preset gets past a site. For scrapers, create a session. It also carries cookies from one response to the next, so challenge cookies issued on the first request are sent back on the second without extra work from you.
2. Keep the fingerprint and the IP coherent
Pick one impersonate target and one proxy, and keep both for the life of a logical session. Cookies like cf_clearance are bound to both. The clearance was issued to a specific fingerprint arriving from a specific IP. If you present it from another address or with another browser signature, expect a new challenge or a block.
In practice:
- When you rotate proxies, start a new session with an empty cookie jar. Don't swap the proxy under an existing one.
- Keep custom headers in line with the preset. A Chrome TLS fingerprint paired with a Safari User-Agent is an easy inconsistency for an anti-bot system to flag.
3. Pin presets deliberately and refresh them
Use curl-cffi list to see which presets you have, pick a recent one, and run curl-cffi update on a schedule. Browser Client Hellos change every few releases. A preset that matched Chrome a year ago may now describe a version that few real users still run, and that makes it stand out.
There are two ways to choose a target, and the choice should be deliberate:
- A versioned preset (a specific Chrome release, for example) gives a fixed fingerprint. You control when it changes, which helps when you're debugging a block.
- An unversioned target such as chrome, safari or safari_ios follows the newest fingerprint the library ships. Your scraper stays current as you upgrade curl_cffi, but the fingerprint can change under you after an update.
4. Set timeouts and retries on the session, not per call
Configure timeout= and retry=RetryStrategy(...) once when you construct the session. You write less code, and every request inherits the same limits, including requests added months later by someone who didn't know the rule. A missing timeout on one call is enough for a stalled proxy to hang a worker. Override the session values per call only for known exceptions, such as a large file download that needs longer.
5. Respect the target
Read robots.txt, keep your request rate somewhere a human could plausibly produce, and back off on 429s. Pacing matters for detection as well as for courtesy. A flawless fingerprint that fires requests faster than any person could click still gets rate-limited, and aggressive retries after a 429 tend to escalate the block. The 429 error guide covers rate-limit handling patterns that work with curl_cffi's retry strategy.
Where curl_cffi stops working
curl_cffi solves TLS and HTTP/2 fingerprinting. It does not run JavaScript, so it can't solve a Cloudflare Turnstile challenge, execute a DataDome sensor script, or render a page that builds its DOM client-side.
When the block is a JS challenge, your self-built options are a real browser (Playwright with a stealth patch, or a fingerprint-controlled browser like Camoufox) to earn the cookies, then hand those cookies to a curl_cffi session for the volume work. That hybrid is the standard pattern: browser for the gate, curl_cffi for the thousand pages behind it. The Cloudflare bypass guide shows the handoff in code.
It also doesn't help when the block is purely IP-based. A flawless fingerprint from a burned datacenter range is still a burned datacenter range.
FAQ
Is curl_cffi free?
Yes. The library is MIT licensed and the Chrome, Safari, and Firefox fingerprint presets are free, including updates via curl-cffi update. The maintainers sell an extended fingerprint database and commercial support at impersonate.pro; you don't need it for anything in this guide.
Does curl_cffi support Firefox?
Yes, since the firefox133 preset, with firefox147 the newest at the time of writing. Guides that say curl_cffi can't do Firefox predate that addition.
Can curl_cffi bypass Cloudflare?
It gets past Cloudflare's TLS-fingerprint check, which is what blocks plain requests on many sites. It cannot solve a managed JavaScript challenge or Turnstile on its own. For those, pair it with a browser that earns the clearance cookie, then reuse the cookie in a curl_cffi session with the same fingerprint and IP.
Is curl_cffi faster than requests?
The maintainers' benchmarks show it ahead of requests and httpx, and roughly level with aiohttp and pycurl, largely because the heavy lifting happens in libcurl rather than Python. Your numbers will depend on the target, so run the benchmark script in the repo against your own endpoints.
What Python version does curl_cffi need?
Python 3.10 or newer since v0.14. If you're on 3.8 or 3.9, upgrade Python before installing; there's no supported workaround.
How do I migrate from requests to curl_cffi?
Change import requests to import curl_cffi, add impersonate="chrome" to your calls or your Session(), and replace any files= uploads with multipart=CurlMime(). Most scripts need nothing else. The python-requests proxy guide has proxy patterns that carry over unchanged.
Wrapping up
You now have a curl_cffi setup that impersonates a current browser, keeps cookies and connections in a session, rotates proxies, retries with backoff, and runs concurrently under asyncio. The one thing to remember: the fingerprint, the headers, and the IP all have to tell the same story, and curl_cffi only controls the first two.
Next, point the async example at a real target and watch the block rate. If it's still high, the problem has moved to IP reputation or a JavaScript gate, and the guides linked above cover both.