Why Proxy Rotation in Crawlee for Python Matters
If you've ever watched a Crawlee crawler grind to a halt after 200 requests because every fetch hit a 403 from Cloudflare, you already know why proxy rotation in Crawlee for Python isn't optional—it's infrastructure. The framework gives you a SessionPool that ties cookies, browser fingerprints, and proxy IPs together, and a ProxyConfiguration class that can pin a residential IP per session or rotate them round-robin. This guide walks through the idiomatic way to wire residential proxies into Crawlee, with runnable code and production pitfalls.
Before diving in: scraping public data is generally legal in many jurisdictions, but the Computer Fraud and Abuse Act (CFAA) in the US and GDPR in the EU set real boundaries. Honor robots.txt, respect rate limits, and prefer official APIs when available. This guide covers technical implementation only—you're responsible for compliance with the sites you access.
Crawlee Python Proxy Architecture: Sessions, Queues, and the Proxy Layer
Crawlee for Python (official docs) is built around a few core primitives that work together in any crawlee python proxy workflow:
- Request Queue — a unified, persistent queue that holds URLs, retry counts, and per-request metadata. Both
BeautifulSoupCrawlerandPlaywrightCrawlerdraw from the same queue. - Autoscaled Pool — scales concurrency based on system resources and response times. You set
max_concurrencyand Crawlee handles the rest. - ProxyConfiguration — the bridge between the SessionPool and your proxy provider. It generates proxy URLs, optionally keyed to a
session_id, so each session gets a consistent IP.
The Crawlee Session Pool and Proxy Binding
The crawlee session pool manages a pool of Session objects, each with its own cookies, fingerprint, and bound proxy IP. When a session gets blocked, you retire it and the pool hands the crawler a fresh one with a different IP. The key insight: the session pool and the proxy configuration are designed to work together. When you pin a proxy to a session via new_url(session_id=...), the session carries that IP across all its requests. Retire the session, and the next one gets a different IP automatically.
Crawlee ProxyConfiguration: Idiomatic Proxy Rotation
Crawlee's ProxyConfiguration supports two modes:
Round-robin rotation — pass a list of proxy URLs and Crawlee cycles through them per request. Simple, but each request gets a different IP, which breaks cookie-based sessions.
Session-pinned rotation — pass a function to new_url that takes a session_id and returns a proxy URL. Each session keeps the same IP for its lifetime, which is what most production crawlers need.
from crawlee.proxy_configuration import ProxyConfiguration
# Round-robin: new IP every request
round_robin_config = ProxyConfiguration(
proxy_urls=[
'http://user-country-US:pass@gate.proxyhat.com:8080',
'http://user-country-DE:pass@gate.proxyhat.com:8080',
'http://user-country-GB:pass@gate.proxyhat.com:8080',
]
)
# Session-pinned: same IP per session, rotated on retire
pinned_config = ProxyConfiguration(
new_url=lambda session_id: (
f'http://user-country-US-session-{session_id}:pass@gate.proxyhat.com:8080'
)
)
With ProxyHat, the session_id goes directly into the username string. The gateway at gate.proxyhat.com:8080 interprets the session-abc123 flag and routes all requests from that session through the same residential IP. When you retire the session in Crawlee, the pool creates a new one with a different ID, and the proxy URL function generates a fresh IP automatically.
You can also combine geo-targeting with session pinning:
# Pin a US residential IP per session, with city-level targeting
pinned_geo_config = ProxyConfiguration(
new_url=lambda session_id: (
f'http://user-country-US-city-newyork-session-{session_id}:pass@gate.proxyhat.com:8080'
)
)
Residential vs Datacenter: Why IP Type Matters for Crawlee
Not all proxies are equal when it comes to anti-bot detection. Here's how the three main types compare for Crawlee workloads:
| Feature | Residential | Datacenter | Mobile |
|---|---|---|---|
| IP origin | ISP-assigned home connections | Data center hosting providers | Cellular carriers |
| Detection risk | Low — looks like a real user | High — flagged by most anti-bot tools | Very low — highest trust score |
| Avg latency | 200–800ms | 50–150ms | 300–1200ms |
| Cost per GB | Mid-tier | Lowest | Highest |
| Best for | Cloudflare/DataDome targets, SERP scraping | Unprotected sites, high-volume simple fetches | Hardcore anti-bot, app-store scraping |
Services like Cloudflare Bot Management and DataDome maintain databases of datacenter IP ranges. A request from an AWS IP block gets flagged almost immediately, while a residential IP from Comcast or Deutsche Telekom blends in with organic traffic. For targets behind these systems, residential proxies aren't a luxury—they're the difference between a 5% success rate and 95%+.
A tiered strategy works well in practice: start with residential proxies for primary requests, fall back to a different geo or mobile proxy on blocks, and reserve datacenter IPs for unprotected endpoints where speed matters more than stealth.
Runnable Example: BeautifulSoupCrawler with ProxyHat
Here's a complete, runnable example using BeautifulSoupCrawler with session-pinned residential proxies from ProxyHat:
import asyncio
from crawlee.beautifulsoup_crawler import BeautifulSoupCrawler
from crawlee.proxy_configuration import ProxyConfiguration
# Each Crawlee session gets a pinned residential IP
proxy_config = ProxyConfiguration(
new_url=lambda session_id: (
f'http://user-country-US-session-{session_id}:pass@gate.proxyhat.com:8080'
)
)
async def main():
crawler = BeautifulSoupCrawler(
proxy_configuration=proxy_config,
max_request_retries=3,
max_session_retries=3,
max_concurrency=10,
)
@crawler.router.default_handler
async def handler(request, session, enqueue_links):
if request.http_status == 403:
# Retire the session — its bound IP is burned
session.retire()
return
if request.http_status == 200:
doc = request.loaded_document
products = doc.select('.product-card')
print(f'Found {len(products)} products on {request.url}')
# Follow pagination
await enqueue_links(selector='.pagination a')
await crawler.run(['https://example.com/products'])
asyncio.run(main())
What happens here: Crawlee creates sessions from the pool, each with a unique session_id. The new_url function embeds that ID in the ProxyHat username, so session-abc123 always routes through the same residential IP. When a 403 comes back, session.retire() discards the session and its IP—the next request gets a fresh session with a new ID and a new IP.
Generating Per-Session Usernames Programmatically
For more control, build session IDs and proxy URLs in a helper function rather than a lambda:
from crawlee.proxy_configuration import ProxyConfiguration
GATEWAY = 'gate.proxyhat.com'
PORT = 8080
USERNAME = 'user'
PASSWORD = 'pass'
def make_proxy_url(session_id: str | None = None,
country: str = 'US',
city: str | None = None) -> str:
"""Build a ProxyHat proxy URL with geo + session targeting."""
parts = [f'country-{country}']
if city:
parts.append(f'city-{city.lower()}')
if session_id:
parts.append(f'session-{session_id}')
suffix = '-'.join(parts)
return f'http://{USERNAME}-{suffix}:{PASSWORD}@{GATEWAY}:{PORT}'
# Use with Crawlee
proxy_config = ProxyConfiguration(
new_url=lambda session_id: make_proxy_url(
session_id=session_id,
country='US',
city='newyork',
)
)
This makes it easy to swap countries, add city-level targeting, or adjust session parameters without rewriting your crawler. Check available proxy locations for supported country and city codes.
Production Patterns: Retries, Concurrency, and Error Handling
Session Retirement on Blocks
The single most important pattern in production: retire sessions aggressively on block signals. A 403 or 429 doesn't just mean "try again"—it means the IP is now flagged. Retiring the session ensures the pool never reuses that IP.
@crawler.router.default_handler
async def handler(request, session, enqueue_links):
# Block signals — retire immediately
if request.http_status in (401, 403, 429):
session.retire()
return
# CAPTCHA challenge — retire and log
if request.http_status == 503:
doc = request.loaded_document
if doc and doc.select_one('.cf-challenge'):
session.retire()
print(f'Cloudflare challenge on {request.url}')
return
# Success — process normally
if request.http_status == 200:
doc = request.loaded_document
# ... your extraction logic
await enqueue_links(selector='a.next-page')
Configuring Retries and Concurrency
Crawlee's autoscaled pool handles concurrency automatically, but you should set sane limits:
max_request_retries=3— how many times a single URL is retried before being marked as failed.max_session_retries=3— how many errors a session tolerates before being retired automatically.max_concurrency=10— upper bound on parallel requests. Start low (5–10) with residential proxies and scale up based on success rates.
With 100 concurrent sessions on residential proxies, you're effectively making 100 requests from 100 different home IPs. That's powerful, but it also means 100 potential IPs to burn if your target's anti-bot is aggressive. Monitor your block rate and adjust concurrency accordingly.
Custom Hooks and Middleware Patterns
Crawlee for Python doesn't have a traditional middleware stack like Scrapy, but you can achieve similar results through its extension points:
- Router handlers — decorated functions that process requests by URL pattern, acting as per-route middleware.
- Session lifecycle hooks —
session.retire()andmax_session_retriescontrol when IPs are cycled. - ProxyConfiguration functions — the
new_urlfunction is your proxy middleware, letting you inject geo-targeting, session pinning, or fallback logic. - Pre-navigation hooks — in PlaywrightCrawler,
pre_navigation_hooklets you modify the page context before requests fire.
from crawlee.playwright_crawler import PlaywrightCrawler
from crawlee.proxy_configuration import ProxyConfiguration
proxy_config = ProxyConfiguration(
new_url=lambda session_id: (
f'http://user-country-US-session-{session_id}:pass@gate.proxyhat.com:8080'
)
)
crawler = PlaywrightCrawler(
proxy_configuration=proxy_config,
max_request_retries=3,
max_concurrency=5,
headless=True,
)
@crawler.pre_navigation_hook
async def set_headers(request, session, page):
await page.set_extra_http_headers({
'Accept-Language': 'en-US,en;q=0.9',
'Sec-Ch-Ua': '"Chromium";v="120"',
})
Keep max_concurrency lower with Playwright—5 concurrent browser instances is a reasonable starting point on a typical server. Each browser instance consumes 150–300 MB of RAM, so plan your container memory accordingly.
Error Handling in Request Handlers
Wrap extraction logic in try/except to prevent one bad page from killing the entire crawl:
@crawler.router.default_handler
async def handler(request, session, enqueue_links):
try:
if request.http_status != 200:
if request.http_status in (403, 429):
session.retire()
return
doc = request.loaded_document
items = doc.select('.item')
if not items:
# Empty page might indicate a soft block
session.retire()
return
for item in items:
# ... extract data
pass
await enqueue_links(selector='.pagination a')
except Exception as e:
print(f'Error on {request.url}: {e}')
# Don't retire on parsing errors — just log and move on
When Not to Use Browser Crawling
PlaywrightCrawler is powerful but expensive—it launches a full browser per session, consuming 150–300 MB of RAM each. Before reaching for it, ask:
- Does the page render content server-side? If the HTML in the initial response contains the data you need, use
BeautifulSoupCrawler. It's 10–50x faster and uses a fraction of the memory. - Is the data behind JavaScript? If the initial HTML is empty and content loads via XHR, you might be able to intercept those API calls directly with
BeautifulSoupCrawler+httpxrather than rendering a full page. - Does the anti-bot require a real browser fingerprint? Some targets (ticketing sites, certain e-commerce platforms) check for browser-specific signals that HTTP-only crawlers can't provide. This is the legitimate use case for
PlaywrightCrawler.
When you do need browser rendering, the same ProxyConfiguration applies—just pass it to PlaywrightCrawler instead of BeautifulSoupCrawler. The session pool and proxy binding work identically across both crawler types.
Scaling: Containerization and Headless Fleets
For large-scale crawls, containerize your Crawlee workers with Docker and distribute across multiple machines. Each container runs an independent Crawlee instance with its own SessionPool, but they can share the same RequestQueue if you configure a storage backend.
Key scaling considerations:
- Session pool size — set
max_pool_sizeto match your concurrency. Each session holds a proxy IP, so 100 sessions means 100 residential IPs in rotation. - Rate per IP — keep requests per IP per minute low. 5–10 requests per minute per residential IP is a safe baseline for most targets.
- Memory — BeautifulSoupCrawler uses ~50 MB per worker; PlaywrightCrawler uses 150–300 MB per browser instance.
- Network — with 50 concurrent residential proxy connections at 200ms average latency, you need enough bandwidth to handle ~250 requests/second.
See web scraping use cases and SERP tracking for architecture patterns specific to those workloads.
Ethics, Legal Boundaries, and Best Practices
A few principles to keep your crawlers on the right side of the line:
- Honor
robots.txt— the RFC 9309 standard defines how crawlers should interpret it. Crawlee doesn't enforce it by default; you can add a middleware or check it manually. - Respect rate limits — if a site returns 429, back off. Proxy rotation helps you avoid triggering rate limits, but it doesn't give you license to ignore them.
- Prefer official APIs — if a site offers an API, use it. Scraping should be a last resort, not a first instinct.
- Public data only — don't scrape behind authentication unless you have explicit permission. The CFAA has been used to prosecute unauthorized access, even when credentials were obtained legally.
- GDPR and CCPA — if you're scraping personal data (names, emails, profile info), you may be processing protected personal data under GDPR. Consult legal counsel for EU-targeted scraping.
Key Takeaways
- Use
ProxyConfigurationwithnew_url(session_id=...)to pin a residential IP per Crawlee session—this is the idiomatic pattern for production crawlers.- Call
session.retire()on 403/429 responses to discard burned IPs automatically. The SessionPool hands you a fresh session with a new IP.- Residential proxies outperform datacenter IPs on Cloudflare and DataDome targets. Use a tiered strategy: residential for hard targets, datacenter for unprotected endpoints.
- Start with
BeautifulSoupCrawlerand escalate toPlaywrightCrawleronly when you need JavaScript rendering or browser fingerprints.- Keep concurrency conservative with residential proxies (5–10 to start), and scale based on observed success rates.
- Honor
robots.txt, respect rate limits, and prefer official APIs. Proxy rotation is a technical tool, not a legal shield.
Ready to wire residential proxies into your Crawlee crawler? Check out ProxyHat pricing for residential, mobile, and datacenter plans, or read the ProxyHat documentation for full gateway configuration details.






