Web Scraping Without Getting Blocked in Python
To scrape in Python without getting blocked, send a real browser's headers from a requests.Session, wait 2 to 5 seconds at random between requests to one site, retry 429 and 5xx responses with exponential backoff that honors Retry-After, stop on 403s and CAPTCHA pages instead of retrying them, and spread the requests over rotating residential IPs. When a site checks the TLS handshake or runs JavaScript tests, switch from requests to curl_cffi or a real browser driven by Playwright. ProxyField's rotating residential proxies plug into all of these through one gateway, with 412M+ IPs from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 11 sections
- Why Python scrapers get blocked
- Step 1: Use a session with real browser headers
- Step 2: Slow down and add jitter
- Step 3: Retry with backoff and honor Retry-After
- Step 4: Route requests through rotating residential proxies
- Step 5: Fix the TLS fingerprint with curl_cffi
- Step 6: Use a real browser only where you need one
- Scrapy settings that prevent blocks
- A checklist before you scale up
- Sources
- Web Scraping Without Getting Blocked in Python FAQ
Why Python scrapers get blocked
A Python scraper gets blocked because it is easy to tell apart from a person in a browser, and because it sends more requests from one IP than a person would. Sites and their bot-protection services check a handful of signals, and a plain requests.get() fails most of them:
| Signal | What a default Python script sends | What a browser sends |
|---|---|---|
User-Agent header | python-requests/2.x (the library's own name, as the Requests docs show) | A current Chrome, Firefox or Safari string |
| Other headers | Only Accept: */* and Accept-Encoding; no Accept-Language | A full, ordered set: Accept, Accept-Language, client hints and more |
| TLS handshake | OpenSSL's handshake, which looks like Python and not like a browser | The browser's own handshake, which bot-protection services fingerprint |
| Request rate | As fast as the loop runs, often several requests a second from one IP | A few pages a minute, with pauses |
| IP address | Your server's datacenter IP | A home or mobile IP |
| JavaScript | Not run at all | Run, including the site's bot checks |
Each row is a separate reason to block you, so fixing one rarely fixes everything. The order that works: rate first, then headers, then IPs, then the TLS client, and a real browser last. The anti-bot hub explains each signal in general.
Step 1: Use a session with real browser headers
Start with a requests.Session: it reuses connections and keeps cookies between requests, as a browser does. Give it the full header set of one real browser, not just a new User-Agent:
# pip install requests
import requests
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
})
r = session.get("https://books.toscrape.com/", timeout=(5, 30))
print(r.status_code, r.request.headers["User-Agent"][:40])- Copy headers from your own browser. Open the page in Chrome, then DevTools, Network, the document request, and copy the request headers. Keep the
User-AgentandAccept-Languageconsistent with each other and with the IP's country. - Rotate whole header sets, not single fields. A Chrome
User-Agentwith Firefox'sAcceptheader is a contradiction that is easy to score. The user agent list and HTTP headers for web scraping go deeper. - Always set a timeout. The Requests documentation warns that without one "your program [can] hang indefinitely" (Requests: Timeouts).
timeout=(5, 30)gives 5 seconds to connect and 30 to read.
Step 2: Slow down and add jitter
Most blocks come from rate, not from anything clever. One IP that requests a page every 200 ms looks like a bot no matter what headers it sends.
- Start at one request every 2 to 5 seconds per site, chosen at random with
random.uniform(2, 5), and raise the rate only while responses stay clean. - Keep concurrency per site low: 1 or 2 parallel requests to one host is a good start. Run other sites in parallel instead.
- Do not refetch what you already have. Cache pages and skip images, fonts and scripts you do not need.
- Prefer the site's JSON endpoints where the page loads its data from them: one request instead of a full page, and often fewer checks. See how to scrape hidden APIs.
There is no universal safe rate; it depends on the site. If you watch your logs, the site will tell you: a rise in 429s, 403s or tiny pages means you are too fast.
Step 3: Retry with backoff and honor Retry-After
Retry the responses that mean "try later" and stop on the ones that mean "no". urllib3, which Requests uses underneath, has a Retry class that does the waiting for you:
import random, time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
retry = Retry(
total=4,
backoff_factor=1, # sleeps 0, 2, 4, 8 s between tries
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"], # never blindly retry POSTs
respect_retry_after_header=True, # wait as long as the site asks
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
session.mount("http://", HTTPAdapter(max_retries=retry))
def fetch(url):
r = session.get(url, timeout=(5, 30))
# crude check: a word match also hits ordinary pages that mention "captcha";
# match the target's real block page (its title or a marker) instead
if r.status_code == 403 or "captcha" in r.text[:20000].lower():
raise RuntimeError(f"blocked: {r.status_code} on {url}") # stop, don't hammer
r.raise_for_status()
return r.text
for url in ["https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/page-2.html"]:
html = fetch(url)
print(url, len(html))
time.sleep(random.uniform(2, 5)) # jitter between pagesThe urllib3 documentation gives the backoff formula: it sleeps backoff_factor * 2 ** (previous retries) seconds with no wait before the first retry, so a factor of 1 gives 0, 2, 4 and 8 seconds; every wait is capped at backoff_max (120 seconds by default), and respect_retry_after_header is on by default (urllib3: Retry). When the retries run out, Requests raises an error that reads "Max retries exceeded with url"; treat it as a signal to slow down, not as a bug.
Two rules keep retries from making things worse:
-
Don't retry a 403 or a CAPTCHA page on the same IP
The site has already decided; more requests only confirm it. Change what caused it (rate, headers, IP type) and come back later. The 403 errors guide and the 429 guide cover each code.
-
Count retries per site
If one site starts failing, back off that whole site, not just the one URL.
Step 4: Route requests through rotating residential proxies
Rotation spreads your requests over many IPs, so no single address crosses the site's rate limit, and residential IPs belong to home internet providers, so sites refuse them as a group less often than datacenter ranges. With ProxyField, a request through the gateway gets a new IP unless you use a sticky session:
PROXY = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
session.proxies = {"http": PROXY, "https": PROXY}
print(session.get("https://api.ipify.org", timeout=(5, 30)).text) # a new IP each call- Rotate per request for independent pages: search results, product pages, listings.
- Keep one IP for a sticky session when several requests must look like one visitor: a login, a cart, paginated results. ProxyField sticky sessions last from 30 seconds to 36 hours. Region, session and session time go in the same username:
-region-<region>,-session-<id>and-ttl-<seconds>(30 to 129,600 seconds, 20 minutes by default), as in<login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. - Match the country of the content: the username above targets New York; ProxyField has 412M+ residential IPs in 195+ countries, with targeting by country, region (state) and city.
- Use datacenter IPs where they work. On sites without strict protection they are faster and cost less per GB ($0.29 down to $0.22 at ProxyField); move to residential when they are refused. Compare the types in residential vs datacenter proxies.
The proxy setup itself, including SOCKS5, async clients and aiohttp, is in our Python proxy guide and the Python requests guide.
Step 5: Fix the TLS fingerprint with curl_cffi
If a site blocks you even with good headers and a residential IP, it may be reading your TLS handshake. Requests uses Python's OpenSSL handshake, which looks nothing like Chrome's, and bot-protection services fingerprint the handshake before your headers are even read. curl_cffi is "a http client that can impersonate browser tls/ja3/http2 fingerprints", with an API close to Requests:
# pip install curl_cffi
from curl_cffi import requests as creq
r = creq.get(
"https://books.toscrape.com/",
impersonate="chrome", # Chrome's TLS and HTTP/2 fingerprint
proxies={"http": PROXY, "https": PROXY},
timeout=30,
)
print(r.status_code)Keep the User-Agent consistent with the browser you impersonate: a Chrome handshake under a Firefox user agent is another contradiction. The mechanics of JA3 and JA4 are in TLS and TCP fingerprinting.
Step 6: Use a real browser only where you need one
Some pages build their content with JavaScript, and some sites send a JavaScript check before the content. There, an HTTP client gets an empty shell or a challenge page, and you need a browser. Playwright runs Chromium, Firefox or WebKit from Python and takes the proxy at launch:
# pip install playwright && playwright install chromium
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(proxy={
"server": "http://gate.proxyfield.io:7000",
"username": "<login>-country-us-city-new_york",
"password": "<password>",
})
page = browser.new_page(locale="en-US", timezone_id="America/New_York")
page.goto("https://books.toscrape.com/", wait_until="domcontentloaded")
print(page.title())
browser.close()Set the locale and time zone to match the IP's location, as above; a New York IP with a browser set to another time zone is an easy flag. A browser costs far more CPU, memory and traffic per page than an HTTP request, because it also loads scripts, styles and images, so use it for the pages that need it and plain HTTP for the rest. More: scraping dynamic pages with Python, and what gives automated browsers away in browser fingerprinting and scraping.
Undetected ChromeDriver is a patched Selenium ChromeDriver that removes the best-known automation traces. Its own README is clear about the limit: "THIS PACKAGE DOES NOT, and i repeat DOES NOT hide your IP address, so when running from a datacenter (even smaller ones), chances are large you will not pass" (undetected-chromedriver on GitHub). Its last PyPI release, 3.5.5, dates from February 2024, and the same author's nodriver project calls itself its official successor.
Scrapy settings that prevent blocks
If you use Scrapy, most of the steps above are settings rather than code:
# settings.py
ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS_PER_DOMAIN = 2
DOWNLOAD_DELAY = 2 # seconds between requests to one site
AUTOTHROTTLE_ENABLED = True # adapts the delay to the site's latency
AUTOTHROTTLE_START_DELAY = 2
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
RETRY_TIMES = 3
DEFAULT_REQUEST_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36")AutoThrottle adjusts the delay so that the spider sends about AUTOTHROTTLE_TARGET_CONCURRENCY requests at a time to each site, based on how fast the site answers (Scrapy: AutoThrottle). Scrapy already retries 429 and the common 5xx codes by default (RETRY_HTTP_CODES), and since version 2.19 it varies DOWNLOAD_DELAY at random by 50% either way (DOWNLOAD_DELAY_JITTER, default 0.5; Scrapy settings). Proxy rotation in Scrapy is in the Scrapy proxy guide.
A checklist before you scale up
| Check | Why |
|---|---|
robots.txt and the terms allow what you collect; the data is public | Blocks aimed at abuse also hit careless scrapers; see how to check if scraping is allowed |
| One session, one header set, one IP per logical visitor | Changing identity mid-session looks like an account takeover |
| Random delay per site, low concurrency per host | Rate is the first signal every site checks |
| Retries only on 429 and 5xx, with backoff | Retrying 403s burns IPs and traffic |
| Status codes and response sizes logged per site | A rise in 403s or tiny pages is the early warning |
| Datacenter first where it works, residential where it does not | Pay residential rates only where they are needed |
When a site still blocks you after all of this, the remaining signals are usually the browser fingerprint and behavior, which proxies do not change. What proxies can and cannot fix separates the two. If you would rather not run browsers and retries yourself, the ProxyField Web Scraping API fetches any URL, with JavaScript rendering when the page needs it, billed on successful results only; the trade-offs are in scraper API vs proxies.
Start small: the ProxyField Easy Trial is 1 GB of residential traffic for $2.95, with no subscription, on the same pool as larger orders. Traffic never expires, and all purchases are final.
Sources
Checked on October 23, 2026:
- Requests: Advanced usage and Quickstart: Timeouts (Requests 2.34.2 on PyPI).
- urllib3: Retry.
- curl_cffi on GitHub (0.16.3 on PyPI).
- undetected-chromedriver (3.5.5 on PyPI, February 2024) and nodriver.
- Scrapy 2.19: AutoThrottle, settings and downloader middleware.
Web Scraping Without Getting Blocked in Python FAQ
How do I scrape websites in Python without getting blocked?
ProxyField residential proxies, from $1.95/GB with 412M+ IPs, cover the IP side of scraping websites in Python without getting blocked: route a requests.Session through the gateway and each request leaves from a new home IP. The rest is your code: send a real browser's full headers, wait 2 to 5 seconds at random between requests to one site, retry only 429 and 5xx responses with backoff, and stop on 403s. Switch to curl_cffi or Playwright when a site checks the TLS handshake or runs JavaScript.
How do I avoid bot detection when scraping with Python?
Remove the signals that set a script apart from a browser: the python-requests user agent, a thin header set, OpenSSL's TLS handshake, a datacenter IP and a machine-regular pace. Fix them in that order with browser headers, random delays, residential IPs and curl_cffi, and use a real browser through Playwright only for pages that run JavaScript checks. Change one thing at a time and watch the share of 403s.
When do you actually need residential proxies for Python scraping?
When your server's IP or datacenter proxies are refused, or when you need many requests to one site or content from a specific country or city. Small jobs on lenient sites run fine from one IP with polite delays, and datacenter proxies are faster and cost less per GB where they work. Move to residential IPs once you see 403s, CAPTCHAs or empty pages that disappear in a normal browser at home.
Why does a Python TLS fingerprint get you blocked?
Because the TLS handshake identifies the client library before any header is read. Python's requests uses OpenSSL with a cipher and extension list no browser sends, so a request that claims to be Chrome in its User-Agent but shakes hands like Python is an easy mismatch to block. curl_cffi with impersonate="chrome" sends Chrome's handshake instead.
Why is curl_cffi still getting blocked?
Because the TLS handshake is only one signal. A site can still block the IP (often a datacenter range), the request rate, headers that contradict the impersonated browser, or the missing JavaScript checks that only a real browser passes. Pair curl_cffi with residential IPs, matching headers and slower pacing, and move to Playwright when the site needs JavaScript.
What is Undetected ChromeDriver?
It is an open-source Python package that patches Selenium's ChromeDriver to remove well-known automation traces, such as the navigator.webdriver flag, so Chrome looks less like an automated browser. It does not change your IP, and its README warns that from a datacenter IP "chances are large you will not pass". Its last PyPI release, 3.5.5, is from February 2024, and the same author's nodriver project calls itself its official successor.
Is Undetected ChromeDriver safe?
It is a public open-source project, so you can read the code, but it downloads and patches the ChromeDriver binary on your machine, and it has not had a PyPI release since February 2024. Install it from PyPI or the official repository only, run it in an isolated environment, and remember that it does not hide your IP. Whether the scraping itself is allowed depends on the site and the data, not on the tool.
Why does my scraper get blocked after a few requests?
Usually because it sends those requests too fast from one IP with a client the site recognizes, which trips a rate limit or a bot score within seconds. Add random delays of a few seconds, send browser headers from a session, keep concurrency per site at 1 or 2, and rotate IPs. If the first request is already blocked, the cause is the IP type or the TLS client, not the rate.
What is a good crawl delay for web scraping?
Start at 2 to 5 seconds between requests to one site, randomized, with 1 or 2 requests in parallel, and honor any Crawl-delay in robots.txt. Shorten the delay only while 429s, 403s and CAPTCHA pages stay near zero. In Scrapy, DOWNLOAD_DELAY with AutoThrottle adjusts the delay to how fast the site answers.
What does "Max retries exceeded with url" mean?
It is the error Requests raises when urllib3 gives up on a URL after the retries you allowed. The text after it names the cause: "too many 429 error responses" means the site kept rate-limiting you, while a connection, timeout or proxy error means the request never got an answer. Slow down for a rate limit; check the proxy address, credentials and timeout for the others.
Show 1 more questionShow fewer questions
Is it safe to retry every request automatically?
No. Retry only what is temporary, such as 429, 500, 502, 503, 504 and timeouts, with growing waits and a cap, and honor Retry-After. Do not retry a 403 or a CAPTCHA page on the same IP, and do not blindly retry POST requests, which can repeat an action. Retrying everything multiplies the load that got you blocked.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.