DEV Community

A Practical Guide to Proxies for Web Scraping (with Python examples)

Why One IP Is Never Enough

Every site you scrape sees the same thing: a stream of requests from one address, arriving faster and more regularly than a human ever would. Anti-bot systems are built to spot exactly that. The signals they use are boring but effective:

  • Request rate per IP. Too many hits in a short window trips a rate limiter.
  • Volume over time. Even a slow scraper eventually stands out if every request comes from the same address for hours.
  • Behavioral fingerprint. No mouse, no scroll, identical headers, requests in perfect intervals.
  • Reputation. Datacenter ranges that have been abused before are pre-flagged.

You can soften some of these with headers, delays, and a real browser, but there is a ceiling. Once a single IP has made enough requests, it gets throttled or blocked regardless of how polite you are. The only way past that ceiling is to spread requests across many addresses, so no single one crosses the threshold. That is the entire job of a proxy pool.

The Proxy Landscape, Minus the Marketing

Providers love to complicate this. For scraping, the distinctions that actually change your results are these:

Shared vs private. Shared proxies are handed to many customers at once. You inherit everyone else's behavior, so an address can already be rate-limited on your target before you send a single request. Private (dedicated) proxies are yours alone, which means the reputation of the IP depends only on how you use it. For serious scraping, private wins almost every time.

Datacenter vs residential. Datacenter proxies live in server ranges: fast, cheap, plentiful. Residential proxies route through real consumer connections: slower, pricier, harder to block. The honest answer is that most scraping jobs do not need residential. If your target is not running aggressive residential-only detection, private datacenter IPv4 with proper rotation handles the vast majority of parsing, price monitoring, and data-collection work at a fraction of the cost and latency.

Static vs rotating. A static IP stays the same; a rotating pool gives you a different address over time or per request. For scraping you almost always want rotation, because it is the mechanism that keeps any single address under the rate limit.

Protocol: HTTP(S) vs SOCKS5. HTTP/HTTPS proxies are the default and work with every scraping library. SOCKS5 operates at a lower level and carries any TCP traffic, which is handy when you are driving tools beyond plain HTTP. Most providers let you use either; pick based on your client.

So the practical sweet spot for a scraping pool is: private, datacenter, rotating IPv4 (or SOCKS5), with low latency. Everything below is built around that assumption.

Rotation Strategies That Matter

"Rotating proxies" is not one thing. There are three patterns, and choosing the wrong one is a common reason scrapers still get blocked despite having a big pool.

  • Per-request rotation. A new IP for every request. Great for stateless scraping (search results, listing pages) where each request is independent. Maximizes spread, minimizes per-IP load.
  • Sticky sessions. The same IP for a sequence of requests, then rotate. Necessary when a site ties a session to an IP (login, multi-step flows, shopping carts). Switching IPs mid-session looks suspicious and breaks state.
  • Time-based rotation. The pool refreshes on an interval. You get a moving set of addresses without managing rotation yourself, which is convenient for long-running crawls.

A good rule: if requests are independent, rotate per request; if they depend on each other, keep a sticky IP for the whole sequence, then move on.

Wiring Proxies Into Python

Let's get concrete. All examples assume a proxy in the standard IP:PORT form, which is the format most providers hand you.

Plain requests

The minimal case:

import requests

proxy = "http://123.45.67.89:8000"
proxies = {
    "http": proxy,
    "https": proxy,
}
resp = requests.get("https://example.com", proxies=proxies, timeout=15)
print(resp.status_code)

If your provider uses login/password instead of IP binding, the URL carries credentials:

proxy = "http://USER:PASS@123.45.67.89:8000"

For SOCKS5, install requests[socks] and change the scheme:

proxies = {
    "http": "socks5://123.45.67.89:1080",
    "https": "socks5://123.45.67.89:1080",
}

Rotating across a pool

A single proxy does not help much. The point is a pool. Here is a small rotator that cycles addresses and retries on failure:

import itertools
import random
import requests

PROXIES = [
    "http://10.0.0.1:8000",
    "http://10.0.0.2:8000",
    "http://10.0.0.3:8000",
    # ... loaded from your provider, one per line
]

def proxy_cycle(proxies):
    pool = proxies[:]
    random.shuffle(pool)
    return itertools.cycle(pool)

cycle = proxy_cycle(PROXIES)

def fetch(url, max_retries=4):
    last_error = None
    for _ in range(max_retries):
        proxy = next(cycle)
        try:
            resp = requests.get(
                url,
                proxies={"http": proxy, "https": proxy},
                timeout=15,
                headers={"User-Agent": "Mozilla/5.0 (compatible; scraper/1.0)"},
            )
            if resp.status_code in (429, 403):
                # this IP is throttled; move on
                continue
            resp.raise_for_status()
            return resp
        except requests.RequestException as e:
            last_error = e
            continue
    raise RuntimeError(f"all retries failed: {last_error}")

Two things worth noting. First, a 429 or 403 is treated as "rotate," not "fail" - the request is fine, the IP is just tired. Second, retries loop through different proxies, so a bad address does not sink the whole request.

Going async with aiohttp

Synchronous requests tops out quickly. For real throughput you want concurrency, and aiohttp handles per-request proxies cleanly:

import asyncio
import random
import aiohttp

PROXIES = [
    "http://10.0.0.1:8000",
    "http://10.0.0.2:8000",
]  # your pool

async def fetch(session, url, sem):
    async with sem:  # cap concurrency so you don't hammer the target
        for _ in range(3):
            proxy = random.choice(PROXIES)
            try:
                async with session.get(url, proxy=proxy, timeout=15) as r:
                    if r.status in (429, 403):
                        continue
                    return await r.text()
            except Exception:
                continue
        return None

async def main(urls):
    sem = asyncio.Semaphore(20)  # 20 in-flight requests
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, u, sem) for u in urls]
        return await asyncio.gather(*tasks)

# asyncio.run(main(list_of_urls))

The Semaphore is not optional. A big pool tempts you to fire thousands of requests at once, but you still need to keep the per-target rate reasonable or you will burn addresses faster than rotation can save them.

Scrapy middleware

If you live in Scrapy, rotation belongs in a downloader middleware so your spiders stay clean:

# middlewares.py
import random

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

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

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

    def process_response(self, request, response, spider):
        if response.status in (429, 403):
            # drop this proxy for the retry and let Scrapy retry
            request.dont_filter = True
            request.meta["proxy"] = random.choice(self.proxies)
            return request
        return response
# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.RotatingProxyMiddleware": 610,
}
PROXY_POOL = [
    "http://10.0.0.1:8000",
    "http://10.0.0.2:8000",
]
RETRY_TIMES = 4
DOWNLOAD_DELAY = 0.5

Combine that with Scrapy's built-in RetryMiddleware and AutoThrottle, and you have a crawler that spreads load, backs off under pressure, and swaps addresses when one gets flagged.

Authentication: IP Allowlist vs Username/Password

There are two ways providers let you authenticate to a proxy, and they change how your code looks.

Username/password. Credentials travel in the request, either in the proxy URL (http://user:pass@ip:port) or via a Proxy-Authorization header. Convenient when your IP changes often - a laptop on the move, a dynamic home connection, CI runners.

IP binding (allowlist). You register the IP of the machine that will use the proxy in your account, and the provider only accepts connections from that address. No credentials in the request at all - the proxy is simply given as IP:PORT. This is cleaner for servers with a fixed address: nothing sensitive sits in your code or logs, and there is no password to leak into a committed config file.

In practice, if you scrape from a dedicated server or a home box with a stable outbound IP, binding is the tidier option - you bind that machine's IP once and every proxy in the pool just works as IP:PORT. If your outbound IP is unstable, use the credentialed format instead so a changed address does not lock you out mid-crawl.

Either way, keep secrets out of version control: load proxy lists and credentials from environment variables or an untracked file, never a hardcoded literal in the repo.

Benchmark Before You Scale

Before you point a thousand-worker crawl at anything, measure. A ten-line benchmark against your real target tells you more than any provider's spec sheet:

import time, requests

def bench(proxy, url, n=20):
    ok, latencies = 0, []
    for _ in range(n):
        t0 = time.perf_counter()
        try:
            r = requests.get(
                url,
                proxies={"http": proxy, "https": proxy},
                timeout=15,
            )
            if r.ok:
                ok += 1
                latencies.append(time.perf_counter() - t0)
        except requests.RequestException:
            pass
    rate = ok / n
    avg = sum(latencies) / len(latencies) if latencies else None
    print(f"success={rate:.0%} avg_latency={avg and round(avg, 2)}s")

Run it against the actual sites you scrape, not against httpbin. Success rate and latency on your targets are the only numbers that matter, and they are exactly what a free trial period is for.

Handling Bans, Retries, and Backoff

Rotation buys you room, but you still need to react when things go wrong. A few patterns that have saved me:

  • Treat status codes as routing signals. 429/403 โ†’ rotate and retry. 5xx โ†’ back off and retry. 404 โ†’ do not retry, it is genuinely gone.
  • Exponential backoff with jitter. When a target pushes back, waiting a fixed 1s from every worker just synchronizes the next burst. Randomize it.
  • Cap retries per URL. Infinite retries turn one bad page into a thread that never dies.
  • Watch your success rate, not just errors. If the share of good responses drops across the whole pool, the problem is your rate or your fingerprint, not the proxies.
  • Slow down before you add more IPs.
import time, random

def backoff(attempt, base=0.5, cap=8):
    delay = min(cap, base * (2 ** attempt))
    time.sleep(delay + random.uniform(0, delay * 0.3))  # jitter

What to Actually Look for in a Proxy Provider

Once the code is in place, results come down to the pool behind it. When I evaluate a provider for scraping, these are the things that move the needle, in order:

  1. Private, not shared. You want the IP's reputation to depend only on your traffic.
  2. A real pool with rotation. Enough addresses that per-request rotation keeps each one well under any rate limit. A pool in the tens of thousands, refreshing on rotation, comfortably covers most crawls.
  3. Low latency. In async scraping, ping is throughput. A 200ms proxy versus a 900ms one is a 4x difference in how many pages you finish per minute at the same concurrency.
  4. Unlimited traffic. Metered gigabytes turn every retry into a cost decision, which is the wrong thing to be optimizing during a crawl.
  5. IPv4 and SOCKS5 support, so the pool fits whatever client you are running.

As a concrete example of that profile, I have been running crawls through private IPv4 and SOCKS5 proxies with rotation from a datacenter pool. The setup is the standard IP:PORT output, access is granted by binding your own IP in the settings, addresses come from a worldmix pool in the tens of thousands, and there is a short free test window that is genuinely useful for one thing: benchmarking latency and success rate against your target before you commit.

That last part matters more than any spec sheet - the only number that counts is how the pool behaves on the specific sites you scrape, so always run a small real test first. Whatever you choose, resist the urge to judge a provider by pool size alone. A smaller pool of fast, private, well-behaved IPs beats a huge pool of slow, shared, pre-flagged ones every time.

Good Scraping Hygiene (So Your IPs Last)

Proxies extend your runway; they do not make you invisible. The scrapers that stay healthy also do the unglamorous things:

  • Respect robots.txt and terms where it matters, and never scrape data you are not allowed to. Proxies are for scale and reliability, not for doing things you should not.
  • Rotate a real set of User-Agent and header combinations, not one fixed string. An IP that rotates but sends identical headers still fingerprints easily.
  • Throttle per target. Global concurrency is fine; per-domain rate should stay human-ish.
  • Cache aggressively. The cheapest request is the one you do not send. Cache responses and dedupe URLs so you are not fetching the same page twice.

Comments

No comments yet. Start the discussion.