Proxy Rotation in Crawlee for Python: A Developer's Guide to Residential IPs

Master proxy rotation in Crawlee for Python with ProxyConfiguration, SessionPool integration, and residential proxies. Includes runnable code, production patterns, and scaling guidance.

Proxy Rotation in Crawlee for Python: A Developer's Guide to Residential IPs

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 BeautifulSoupCrawler and PlaywrightCrawler draw from the same queue.
  • Autoscaled Pool — scales concurrency based on system resources and response times. You set max_concurrency and 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 hookssession.retire() and max_session_retries control when IPs are cycled.
  • ProxyConfiguration functions — the new_url function is your proxy middleware, letting you inject geo-targeting, session pinning, or fallback logic.
  • Pre-navigation hooks — in PlaywrightCrawler, pre_navigation_hook lets 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 + httpx rather 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_size to 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 ProxyConfiguration with new_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 BeautifulSoupCrawler and escalate to PlaywrightCrawler only 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.

Ready to get started?

Access 50M+ residential IPs across 148+ countries with AI-powered filtering.

View PricingResidential Proxies
← Back to Blog