How to Set Up a Custom Proxy in Scrapy (Production-Ready)

Last Updated on August 11, 2026
Hand-drawn Scrapy crawler routing through a proxy pool, retrying a 503 route and completing through a 200 route
AI Summary
- Build a custom Scrapy proxy middleware that assigns approved routes, adds authentication safely, records failure reasons, and preserves request metadata across retries. - Understand downloader middleware ordering, especially how custom proxy logic interacts with HttpProxyMiddleware, RetryMiddleware, redirects, and exception handling. - Add rotation with bounded attempts, cooldowns, proxy health state, and target-aware policies instead of choosing a random proxy for every request. - Distinguish proxy authentication failures, connection errors, DNS issues, target 403 responses, and rate limits so each condition receives the correct response. - Use production safeguards for secret storage, concurrency, observability, session consistency, and fail-closed behavior when no approved proxy remains available.

Your Scrapy spider crushes it on 100 test pages, then falls apart at 10,000. That's not really a scraping bug — it's a networking problem wearing a scraping bug's costume. The fix everyone reaches for is a proxy, and slapping one into request.meta takes about thirty seconds.

The problem is that the thirty-second fix is where many basic tutorials stop. They show you meta={"proxy": "http://IP:PORT"}, maybe a middleware class, and call it a day. They often omit what happens when that proxy dies mid-crawl, how credentials leak into Git history, or why a retry may reuse the same failed proxy. Those are the production concerns this guide covers: fail-closed configuration, secret management, deliberate proxy selection on retry, protocol limits, and measurable cost trade-offs.

What Is a Proxy Middleware in Scrapy?

A proxy middleware is a piece of code that sits in Scrapy's downloader pipeline and decides which IP address a request should appear to come from before it hits the wire. Scrapy ships with a built-in one, HttpProxyMiddleware, that reads a proxy key from request.meta, attaches proxy authentication if needed, and hands the request off to the download handler that actually opens the connection. Custom proxy middleware doesn't replace that transport step — it's a selector that decides which proxy to plug in before the built-in machinery takes over. That distinction matters more than it sounds like it should, and it's the source of most "my custom middleware isn't working" bug reports.

Why Proxies Matter for Production Scrapy Projects

A proxy changes the network route and apparent source IP. That can help with legitimate geo-specific testing, distribute authorized request traffic, and isolate network failures. It does not grant permission, bypass rate limits, or guarantee access. Whether a crawl needs a proxy at all depends on the target, its terms, request rate, and the reliability requirements of the job.

Not all proxies are created equal, and picking the wrong tier is its own kind of production incident:

Proxy TypeReliabilitySpeedDetection RiskTypical Cost
Free public proxiesHighly variableVariableOften highNo fee, but material security/operational risk
Datacenter proxiesProvider- and target-dependentOften fastTarget-dependentCommonly priced per GB or IP
Residential proxiesProvider- and target-dependentVariableTarget-dependentCommonly priced per GB
ISP proxiesProvider- and target-dependentVariableTarget-dependentProvider-specific

Free proxies deserve a specific callout because the measured risks are substantial. The 30-month Free Proxies Unmasked study tracked more than 640,000 addresses from 11 providers: 34.5% were active at least once, and 16,923 manipulated content. That population supports a strong security warning for public lists; it is not a portable failure rate for every current list, paid pool, target, or workload.

Proxies also don't magically defeat modern bot detection. Services like Cloudflare score requests using dozens of signals — TLS fingerprints, header consistency, JavaScript execution, behavioral patterns — where your IP address is just one input among many. A clean residential proxy on a request with inconsistent headers and no cookie jar will still get flagged. Keep that expectation in check before you build an entire architecture around "just rotate the IP."

Before You Start

  • Difficulty: Intermediate
  • Time Required: ~30–45 minutes for the full production setup, ~5 minutes for the quick test
  • What You'll Need: Python 3.9+, Scrapy installed (this guide is tested against Scrapy 2.17.0, released July 2026), a working spider from scrapy startproject, and at least one proxy endpoint (a free trial from any datacenter proxy provider works fine for testing)

Step 1: Test a Proxy with the Request Meta Parameter

The fastest way to confirm a proxy works is to bypass all the middleware architecture entirely and just try it.

Scrapy's built-in HttpProxyMiddleware reads a proxy key straight out of request.meta and routes the request through it. No settings changes, no middleware class — just one keyword argument.

import scrapy

class ProxyTestSpider(scrapy.Spider):
    name = "proxy_test"
    start_urls = ["https://httpbin.org/ip"]

    def start_requests(self):
        for url in self.start_urls:
            yield scrapy.Request(
                url,
                meta={"proxy": "http://203.0.113.10:8080"},
                callback=self.parse,
            )

    def parse(self, response):
        self.logger.info(response.text)

Run it with scrapy runspider proxy_test.py. If the proxy works, httpbin.org/ip will echo back the proxy's IP address instead of your own — that's your confirmation. If the response hangs and eventually throws a TCP connection timed out error, the proxy is dead or unreachable, which, spoiler, happens more often than proxy vendors like to admit.

This method is fine for one-off spiders or quick tests. It falls apart the moment you have more than one spider, because now you're hardcoding the same proxy string in five different files.

Step 2: Build a Custom Proxy Middleware

For anything beyond a single spider, you want the proxy logic centralized in one place. Create a ProxyMiddleware class in your project's middlewares.py:

from scrapy.exceptions import NotConfigured

class ProxyMiddleware:
    def __init__(self, proxy_url):
        self.proxy_url = proxy_url

    @classmethod
    def from_crawler(cls, crawler):
        proxy_url = crawler.settings.get("PROXY_URL")
        if not proxy_url:
            raise NotConfigured("PROXY_URL is required; refusing silent direct fallback")
        return cls(proxy_url=proxy_url)

    def process_request(self, request, spider):
        if "proxy" not in request.meta:
            request.meta["proxy"] = self.proxy_url

And register it in settings.py:

import os

PROXY_URL = os.environ.get("PROXY_URL")

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.ProxyMiddleware": 350,
}

Note the from_crawler classmethod instead of a plain __init__. This is the pattern Scrapy actually recommends for reading settings, and it's the exact hook we'll lean on again in Step 4 when we talk about secrets. The upside here is simple: change one setting, every spider in the project picks up the new proxy. No more grepping through five files to swap a dead IP.

Step 3: Understand Middleware Execution Order (So Your Proxy Doesn't Silently Do Nothing)

Here's the part almost every other tutorial glosses over with "just set the priority to 350, trust me." Downloader middleware in Scrapy runs in a specific, predictable order, and if you don't understand it, your proxy setup will produce bugs that look completely unrelated to proxies.

Request-side hooks (process_request) run in ascending priority order — lowest number first. Response-side hooks (process_response, process_exception) run in descending order — highest number first, unwinding back down the chain.

Request flow (ascending):
  Spider → [100] RobotsTxt → [300] HttpAuth → [350] YOUR PROXY MIDDLEWARE
         → [500] UserAgent → [550] Retry → [700] Cookies → [750] HttpProxy
         → Downloader

Response flow (descending):
  Downloader → [750] HttpProxy → [700] Cookies → [550] Retry
             → [500] UserAgent → [350] YOUR PROXY MIDDLEWARE → [300] HttpAuth
             → [100] RobotsTxt → Spider

Here's the real, current default priority table for Scrapy's built-in middlewares (verified against 2.17.0):

MiddlewareDefault Priority
RobotsTxtMiddleware100
HttpAuthMiddleware300
DownloadTimeoutMiddleware350
DefaultHeadersMiddleware400
UserAgentMiddleware500
RetryMiddleware550
RedirectMiddleware600
CookiesMiddleware700
HttpProxyMiddleware750
DownloaderStats850
HttpCacheMiddleware900

When RetryMiddleware schedules a retry, it copies the failed request—including its metadata—and that new request re-enters the downloader middleware chain. The selector's numeric relationship to priority 550 does not guarantee rotation. Rotation happens only when selector code recognizes the retry and deliberately overwrites the copied meta["proxy"]. Priority 350 is a convenient place for a selector because it runs before the built-in transport step at 750, but it is not a rotation mechanism by itself.

Scrapy request and response paths through priorities 350, 550, and 750, with a 503 retry re-entering the middleware chain

Common Middleware Ordering Mistakes

  • Setting your middleware to the same priority as HttpProxyMiddleware (750): This creates a race condition where Scrapy's dict-ordering (not your logic) decides which middleware processes the request first. Symptom: intermittent, unexplainable proxy behavior.
  • Using setdefault() or if "proxy" not in request.meta in a rotating selector: the copied retry keeps the old proxy. Symptom: every retry repeats the same failed route. Fix: detect a retry (for example, retry_times > 0) and explicitly replace the selector-owned proxy value.
  • Disabling HttpProxyMiddleware entirely: Some tutorials tell you to set "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None because "the custom middleware handles it." It doesn't — your custom middleware is a selector, not a transport layer. Disabling the built-in one means proxy authentication headers never get attached, and your requests silently go out unauthenticated (or don't go out at all).

Step 4: Stop Hardcoding Proxy Credentials

Every top-ranking Scrapy proxy tutorial I found writes http://username:password@proxy.example.com:8080 directly into a Python file. That's a credential sitting in your Git history forever, showing up in every clone, every fork, every log dump if someone gets sloppy with print() statements.

There are really three tiers of doing this:

MethodSecurityFlexibilityBest For
Hardcoded in spider/settings.pyPoor — secrets live in the repoLowQuick local testing only
http_proxy environment variable (Scrapy-native)Better — out of codeLow (one proxy)CI/CD pipelines, Docker
.env file + python-dotenv + from_crawlerBest — out of code, per-environmentHigh (multiple proxies, rotation)Production scrapers

The third option is worth building properly. Install python-dotenv, create a .env file (and immediately add it to .gitignore — I mean it, do this now):

PROXY_USER=myuser
PROXY_PASSWORD=my$ecret!Pass
PROXY_HOST=proxy.example.com
PROXY_PORT=8080

Load it at the top of settings.py:

from dotenv import load_dotenv
import os

load_dotenv()

PROXY_USER = os.getenv("PROXY_USER")
PROXY_PASSWORD = os.getenv("PROXY_PASSWORD")
PROXY_HOST = os.getenv("PROXY_HOST")
PROXY_PORT = os.getenv("PROXY_PORT")

Then read it securely inside your middleware using from_crawler — this is the pattern that solves the exact confusion I've seen on forums where people ask "how do I set this before running scrapy crawl if the credentials need to change per-run?":

import os
from urllib.parse import quote

class SecureProxyMiddleware:
    def __init__(self, user, password, host, port):
        self.user = quote(user, safe="")
        self.password = quote(password, safe="")
        self.host = host
        self.port = port

    @classmethod
    def from_crawler(cls, crawler):
        settings = crawler.settings
        return cls(
            user=settings.get("PROXY_USER"),
            password=settings.get("PROXY_PASSWORD"),
            host=settings.get("PROXY_HOST"),
            port=settings.get("PROXY_PORT"),
        )

    def process_request(self, request, spider):
        proxy_url = f"http://{self.user}:{self.password}@{self.host}:{self.port}"
        request.meta["proxy"] = proxy_url

Note the urllib.parse.quote() call around the credentials. If your password contains a @, :, or / (and password generators love throwing these in), it'll break the URL parsing unless it's percent-encoded first. This is a one-line fix that saves an embarrassing hour of debugging "invalid proxy URL" errors that have nothing to do with your proxy actually being invalid.

Keep credentials out of logs and test percent-encoding with representative—but fake—values. HTTPS tunneling, SOCKS5, and non-Latin credentials have handler-specific boundaries; do not assume an authentication pattern works across them without a pinned integration test.

Secure flow from a locked environment file through percent-encoded proxy settings into Scrapy request metadata

Step 5: Add Proxy Rotation

A single proxy — even a good one — making 5,000 requests to the same site will eventually get flagged. You need a pool.

Option A — build it yourself. This is genuinely simple:

import random

class RotatingProxyMiddleware:
    def __init__(self, proxy_pool):
        self.proxy_pool = proxy_pool

    @classmethod
    def from_crawler(cls, crawler):
        return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))

    def process_request(self, request, spider):
        request.meta["proxy"] = random.choice(self.proxy_pool)

This works for basic use but has zero awareness of which proxies are actually alive. You're rolling dice every request.

Option B — use scrapy-rotating-proxies. This third-party package adds ban detection and automatic backoff out of the box:

pip install scrapy-rotating-proxies

I'll flag something honestly here: the latest release on PyPI is version 0.6.2, dated 2019, and the project is tagged Alpha. It's not necessarily broken on modern Scrapy, but "not actively maintained since 2019" is not the same as "battle-tested for 2026 production traffic." Pin the version, test it against your actual target sites, and don't assume it handles authenticated proxy endpoints — it largely doesn't.

Step 6: Build a Fault-Tolerant Proxy Middleware (Dead Proxy Detection)

This is the section every competitor tutorial skips entirely, and it's the difference between a demo and something that survives a 6-hour crawl unattended.

ScenarioWhat most tutorials showWhat this middleware adds
Proxy returns 407Not addressedTreats it as a proxy-authentication failure
Target returns 403/429Often grouped togetherKeeps policy/rate feedback separate from proxy health
Proxy times outNot addressedConfigurable timeout threshold, health score decay
All proxies deadNot addressedGraceful fallback or crawl pause with a logged warning
Proxy flapping (intermittent)Not addressedCool-down period before re-adding to the pool
import time
import random
from scrapy.exceptions import IgnoreRequest

class FaultTolerantProxyMiddleware:
    MAX_FAILURES = 3
    COOLDOWN_SECONDS = 300

    def __init__(self, proxy_pool):
        self.pool = {p: {"failures": 0, "banned_until": 0} for p in proxy_pool}

    @classmethod
    def from_crawler(cls, crawler):
        return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))

    def _healthy_proxies(self):
        now = time.time()
        return [p for p, s in self.pool.items() if s["banned_until"] < now]

    def process_request(self, request, spider):
        healthy = self._healthy_proxies()
        if not healthy:
            spider.logger.warning("All proxies unhealthy — pausing crawl")
            raise IgnoreRequest("No healthy proxies available")
        request.meta["proxy"] = random.choice(healthy)

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy")
        if proxy and response.status == 407:
            self._mark_failure(proxy)
        return response

    def process_exception(self, request, exception, spider):
        proxy = request.meta.get("proxy")
        if proxy:
            self._mark_failure(proxy)

    def _mark_failure(self, proxy):
        state = self.pool[proxy]
        state["failures"] += 1
        if state["failures"] >= self.MAX_FAILURES:
            state["banned_until"] = time.time() + self.COOLDOWN_SECONDS
            state["failures"] = 0

A few notes from actually building this: don't treat every 403 as proof the proxy itself is dead — RFC 9110 defines 403 as "the server understood but refuses," which could just as easily mean your headers or session look suspicious, proxy notwithstanding. A 407, on the other hand, means the proxy specifically is rejecting your authentication — that's a stronger and more specific signal. Mixing these signals into one bucket is how people end up burning healthy proxies for no reason.

Keep Scrapy's transient retry defaults explicit in production so reviewers can see the policy:

RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
RETRY_TIMES = 2

Those are Scrapy 2.17's defaults. Do not blanket-add 403 or 407: a 403 is target refusal with many possible causes, while 407 is a proxy-authentication problem that generic request retrying will not repair. If a specific target contract justifies retrying another status, document that reason and test it separately.

Distinct handling for 407 authentication failure, 429 rate limiting, 503 retry, 200 success, and a closed gate when proxies are unavailable

Step 7: A Measured Proxy-on-Retry Pattern

Some authorized targets may return valid content directly and need a proxy only after a documented transient response. That can reduce proxied bytes, but there is no defensible universal savings percentage or portable direct-request success rate. Measure your own workload before adopting the pattern.

Use Scrapy's public get_retry_request() helper and keep escalation limited to target-specific statuses you have explicitly classified. This example treats 429 and 503 as retryable pressure signals; it intentionally excludes 403 and 407.

from scrapy.downloadermiddlewares.retry import get_retry_request

class CostAwareEscalationMiddleware:
    def process_response(self, request, response, spider):
        if response.status not in {429, 503}:
            return response

        retry = get_retry_request(
            request,
            spider=spider,
            reason=f"proxy_escalation_{response.status}",
            max_retry_times=2,
        )
        if retry is None:
            return response

        current_tier = request.meta.get("proxy_tier", "direct")
        if current_tier == "direct":
            retry.meta["proxy"] = spider.settings["DATACENTER_PROXY"]
            retry.meta["proxy_tier"] = "datacenter"
        elif current_tier == "datacenter":
            retry.meta["proxy"] = spider.settings["RESIDENTIAL_PROXY"]
            retry.meta["proxy_tier"] = "residential"
        else:
            return response
        return retry

Log direct valid-content rate, proxied valid-content rate, bytes per successful record, retries per success, and added latency. A direct-first design is only acceptable when direct access is authorized and the system fails closed whenever a proxy is required. The saving is the measured reduction in proxied traffic—not an assumed percentage.

When DIY Proxy Management Isn't Worth It

I want to be straight about this instead of pretending the entire rest of this article was pointless: everything above is real, useful engineering, and for a lot of projects — high-volume crawls, custom pipelines, anything where you need full control over request scheduling — it's the right call.

But if your actual goal is "get structured data from this page" rather than "operate proxy infrastructure," an extraction API may be a better boundary. Thunderbit's current POST /extract documentation accepts a page URL and an optional JSON Schema; when the schema is omitted, the service can generate one from the page content. The endpoint also exposes none, basic, and full render modes, along with timeout and post-load wait controls. Prompt-only extraction is not part of the current supported request surface, so build production integrations around the documented schema contract. This moves the extraction interface behind one request; it does not justify a universal promise about every anti-bot or CAPTCHA target.

FactorDIY Scrapy + ProxiesAPI-based extraction (e.g., Thunderbit)
Setup effortHigh — middleware, rotation, retry logicLow — one API call with a schema
Network/rendering behaviorYou configure handlers, proxies, headers, and delaysControlled through documented API options
MaintenanceYou own selectors, pool health, and target changesYou own schema quality, validation, and integration behavior
Cost modelProxy fees + compute + engineering timeCurrent docs list 20 units per page (checked 2026-08-10)
ControlFull — custom pipelines, middleware chainLimited to API capabilities
Best forComplex crawls, high volume, custom logicTargeted extraction, prototyping, enrichment

If you're mostly extracting structured pages for lead lists, product data, or research, run a representative pilot with the Thunderbit Chrome Extension or the API and compare valid records, latency, units, and maintenance time. If your project needs custom crawl graphs and pipeline control, Scrapy remains the stronger fit.

Learn More

Tips & Common Pitfalls

  • Tip: Test proxies against httpbin.org/ip before pointing them at a real target. It's the fastest way to confirm routing works before you add complexity on top.
  • Pitfall: Setting a proxy in request.headers instead of request.meta. This is a genuinely common typo-adjacent bug — HttpProxyMiddleware only reads meta["proxy"], and a header-based attempt will fail silently with no obvious error.
  • Pitfall: Assuming HTTP and HTTPS proxies configure identically. An HTTP proxy URL routing to an HTTPS destination generally works via CONNECT tunneling under a compatible handler, but using an https:// scheme for the proxy endpoint itself is a different, less-supported configuration — don't conflate the two.
  • Tip: If you need SOCKS5 support, check your Scrapy version's download handler capabilities first. Scrapy 2.17's experimental Httpx handler added SOCKS5 support via httpx[socks], but it's still labeled experimental — don't build a production dependency on it without your own testing.

Alternative Methods

Beyond scrapy-rotating-proxies, some teams route all proxy logic through a provider gateway — a single proxy URL where the vendor handles rotation, session stickiness, and geo-targeting behind the scenes. This trades a bit of control for a lot less middleware code, and it's worth pricing out against a self-managed pool before you build one from scratch.

Conclusion

Setting a proxy in Scrapy takes one line. Making that setup survive a real production crawl requires explicit failure policy, protected credentials, tested handler boundaries, and selector code that deliberately replaces copied proxy metadata on retry. If you walk away with two habits, make them these: fail closed when a proxy is required, and never commit a proxy password to a Python file.

FAQs

How do I set up a custom proxy in Scrapy with authentication? Use the protocol://username:password@host:port URL format, but percent-encode the username and password with urllib.parse.quote() first if they contain special characters. For production, read those credentials via a from_crawler classmethod pulling from environment variables rather than hardcoding them.

What priority number should I use for my custom proxy middleware in Scrapy? 350 is a common selector priority because it runs before HttpProxyMiddleware at 750. It does not guarantee rotation. A retry gets a fresh proxy only if the selector recognizes the copied retry request and overwrites its prior meta["proxy"] value.

How do I handle dead proxies in Scrapy automatically? Build a middleware that tracks failure counts per proxy in process_response and process_exception, removes proxies from the active pool after a failure threshold, and re-adds them after a cool-down period rather than banning them permanently.

Can I use Scrapy with SOCKS5 proxies? Scrapy 2.17's experimental HttpxDownloadHandler documents SOCKS5 support when httpx[socks] is installed. The default HTTP/1.1 handler does not support SOCKS proxies. Pin the handler/version and run an integration test before treating this path as production-ready.

How much can the proxy-on-retry pattern actually save on proxy costs? There is no portable percentage. Measure the share of authorized requests that return valid content directly, the bytes sent through each proxy tier, retries per successful record, and added latency. The observed reduction in proxied bytes is your saving; if direct-first access is not authorized or valid, do not use this pattern.

Ke
Ke
CTO at Thunderbit | Senior Data Scientist & ML Expert With nearly a decade of experience in machine learning and data science, Ke Shen is a Columbia University alumnus and former Senior Data Scientist at Walmart Labs. With deep, peer-recognized expertise in Python, R, Java, and Statistics, he shares battle-tested insights on taking complex AI algorithms from theory to production-grade architecture.
Topics
Scrapy proxy middlewarePython web scrapingProxy rotation
Table of Contents
Thunderbit · AI web data agent

Extract data from any page in 1 click

Trusted by 250,000+ users
free plan available
Extract Data using AI
Easily transfer data to Google Sheets, Airtable, or Notion
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week