๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

How to Rotate Proxies in Python

Rotate proxies in Python with round-robin, random, and weighted strategies. Covers requests, httpx, aiohttp, Scrapy, retry logic, and thread-safe proxy pools.

S SparkProxy 6 15 min read
Share
How to Rotate Proxies in Python

Rotating proxies in Python sounds simple until your script is running 50 threads and every call to next(cycle_iterator) is racing. itertools.cycle is not thread-safe. Using it across threads without a lock silently skips proxies, duplicates others, and introduces race conditions nobody notices until traffic spikes. This guide covers every pattern you actually need to rotate proxies in Python: round-robin, random, weighted, retry-on-failure, thread-safe pool class, async rotation with httpx and aiohttp, and Scrapy middleware, with the gotchas documented for each.

Why Rotate Proxies?

Sending many requests from a single IP triggers rate limits, CAPTCHAs, and IP bans on target sites. A rotating proxy pool spreads requests across multiple IPs so each IP stays below the target site's detection threshold.

ScenarioRotation Strategy
High-volume scraping (>1k req/day per domain)Per-request random or round-robin
Multi-step flows (login โ†’ navigate โ†’ checkout)Per-session: same proxy for the entire session
Geo-specific data collectionWeighted: favor proxies in the target country
Mixed-reliability pool (datacenter + residential)Weighted: higher weight to faster/more reliable proxies

The rotation strategy you choose affects both performance and detection avoidance. Per-request rotation is the most common for stateless scraping; per-session is required when the target site tracks session consistency across requests (cookies, login state).

proxy pool


Round-Robin Rotation with itertools.cycle

Round-robin assigns proxies in a fixed repeating sequence, proxy 1, proxy 2, proxy 3, proxy 1, โ€ฆ, ensuring every proxy gets equal use.

import itertools
import requests

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

proxy_cycle = itertools.cycle(PROXIES)

def get(url: str) -> requests.Response:
    proxy = next(proxy_cycle)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=10)

for url in ["https://httpbin.org/ip"] * 6:
    resp = get(url)
    print(resp.json()["origin"])

The thread-safety problem

itertools.cycle stores internal state in a C-level iterator object. Calling next() on it from multiple threads without a lock is not thread-safe, Python's GIL does not protect compound state transitions inside C extensions. In practice this causes:

  • Two threads receiving the same proxy
  • Proxies being skipped under load
  • No error raised (silent misbehavior)

Fix: wrap next() in a threading.Lock:

import itertools
import threading

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

_cycle = itertools.cycle(PROXIES)
_lock  = threading.Lock()

def next_proxy() -> str:
    with _lock:
        return next(_cycle)

Or use queue.Queue (see Thread-Safe Proxy Pool Class).


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Random Proxy Rotation

Random rotation picks a proxy uniformly at random for each request. It provides better entropy than round-robin when one proxy in the pool is degraded, a round-robin pool will keep hitting the bad proxy every N requests, while random rotation naturally reduces its frequency.

import random
import requests

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

def get(url: str) -> requests.Response:
    proxy = random.choice(PROXIES)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=10)

random.choice() is thread-safe in CPython, it uses the module-level Random instance whose __next__ method is protected by the GIL. However, random.seed() is not thread-safe; if you call it from multiple threads, use separate random.Random instances instead.


Weighted Proxy Rotation

Use weighted rotation when proxies have different reliability, speed, or geographic value. random.choices() accepts a weights parameter:

import random
import requests

# (proxy_url, weight), higher weight = selected more often
PROXY_POOL = [
    ("http://us-fast-1.sparkproxy.io:10000", 5),
    ("http://us-fast-2.sparkproxy.io:10001", 5),
    ("http://eu-medium.sparkproxy.io:10002", 3),
    ("http://fallback.sparkproxy.io:10003",  1),
]

PROXIES   = [p for p, _ in PROXY_POOL]
WEIGHTS   = [w for _, w in PROXY_POOL]

def get(url: str) -> requests.Response:
    proxy = random.choices(PROXIES, weights=WEIGHTS, k=1)[0]
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=10)

random.choices() normalizes weights automatically, no need to sum to 1.0. To dynamically adjust weights based on success/failure rates, update the weights list based on response metrics (latency, error count) between batches.


Retry on Proxy Failure with urllib3 and requests

Python proxy rotation without retry logic fails silently when a proxy goes down, you get ProxyError or ConnectionError and lose the request. Use urllib3.Retry with a requests.HTTPAdapter to automatically retry with the next attempt:

import random
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

def make_session(proxy: str) -> requests.Session:
    """Create a session bound to one proxy with retry logic."""
    retry = Retry(
        total=3,
        backoff_factor=0.5,           # 0s, 0.5s, 1s between retries
        status_forcelist=[407, 429, 500, 502, 503, 504],
        allowed_methods=["GET", "POST"],
        raise_on_status=False,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("http://",  adapter)
    session.mount("https://", adapter)
    session.proxies = {"http": proxy, "https": proxy}
    return session


def get_with_rotation(url: str, retries: int = 3) -> requests.Response:
    """Try up to `retries` different proxies on failure."""
    proxies = random.sample(PROXIES, min(retries, len(PROXIES)))
    last_exc = None

    for proxy in proxies:
        session = make_session(proxy)
        try:
            resp = session.get(url, timeout=15)
            resp.raise_for_status()
            return resp
        except requests.exceptions.RequestException as exc:
            last_exc = exc
            continue  # Try next proxy

    raise RuntimeError(f"All {len(proxies)} proxies failed for {url}") from last_exc

Key parameters in Retry:

ParameterRecommended ValueEffect
`total`3Max total retry attempts per session
`backoff_factor`0.5Exponential delay between retries (0s, 0.5s, 1s)
`status_forcelist``[407, 429, 500, 502, 503, 504]`Retry on these HTTP status codes
`raise_on_status``False`Do not raise on 4xx/5xx, let caller decide

Note: 407 Proxy Authentication Required in status_forcelist handles the case where proxy credentials expire mid-session. The retry will re-attempt the full request.


Thread-Safe Proxy Pool Class

For multi-threaded scrapers, use a queue.Queue-backed pool. queue.Queue.get() and queue.Queue.put() are both thread-safe by design. The pool marks failed proxies and re-queues healthy ones:

import queue
import threading
import time
import requests


class ProxyPool:
    """Thread-safe rotating proxy pool with failure tracking."""

    def __init__(self, proxies: list[str], max_failures: int = 3):
        self._pool: queue.Queue[str] = queue.Queue()
        self._failures: dict[str, int] = {}
        self._lock = threading.Lock()
        self._max_failures = max_failures

        for proxy in proxies:
            self._pool.put(proxy)
            self._failures[proxy] = 0

    def get(self, block: bool = True, timeout: float = 5.0) -> str:
        """Acquire a proxy from the pool (blocks if empty)."""
        return self._pool.get(block=block, timeout=timeout)

    def release(self, proxy: str, success: bool) -> None:
        """Return proxy to pool. Remove it if it exceeded failure threshold."""
        with self._lock:
            if success:
                self._failures[proxy] = 0
                self._pool.put(proxy)
            else:
                self._failures[proxy] += 1
                if self._failures[proxy] < self._max_failures:
                    self._pool.put(proxy)
                # else: proxy is evicted from the pool

    def size(self) -> int:
        return self._pool.qsize()


# Usage
pool = ProxyPool([
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
])

def fetch(url: str) -> str | None:
    proxy = pool.get(timeout=5.0)
    try:
        resp = requests.get(
            url,
            proxies={"http": proxy, "https": proxy},
            timeout=10,
        )
        resp.raise_for_status()
        pool.release(proxy, success=True)
        return resp.text
    except requests.exceptions.RequestException:
        pool.release(proxy, success=False)
        return None


# Multi-threaded example
from concurrent.futures import ThreadPoolExecutor

urls = [f"https://httpbin.org/ip?n={i}" for i in range(20)]

with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fetch, urls))

print(f"Success: {sum(r is not None for r in results)}/{len(urls)}")

This pattern guarantees:

  • No two threads use the same proxy concurrently (queue get() is atomic)
  • Failed proxies are retired after max_failures consecutive failures
  • Pool size is always known via size()

Async Proxy Rotation with httpx and aiohttp

Automatic proxy rotation in async Python requires selecting a new proxy per request without blocking the event loop. Both httpx and aiohttp support per-request proxy configuration.

httpx async rotation

import asyncio
import random
import httpx

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

async def fetch(client: httpx.AsyncClient, url: str) -> dict:
    proxy = random.choice(PROXIES)
    # httpx AsyncClient supports per-request proxy override via transport
    async with httpx.AsyncClient(proxy=proxy) as c:
        resp = await c.get(url, timeout=10)
        return {"url": url, "ip": resp.json().get("origin"), "proxy": proxy}

async def main():
    urls = ["https://httpbin.org/ip"] * 10
    tasks = [fetch(None, url) for url in urls]
    results = await asyncio.gather(*tasks)
    for r in results:
        print(r)

asyncio.run(main())

For high-concurrency (>50 concurrent requests), reuse a single AsyncClient and use the mounts parameter to avoid creating a new connection pool per request:

import asyncio
import random
import httpx

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

async def main():
    urls = ["https://httpbin.org/ip"] * 20

    async def fetch(url: str) -> str:
        proxy = random.choice(PROXIES)
        async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
            resp = await client.get(url)
            return resp.json().get("origin", "error")

    results = await asyncio.gather(*[fetch(u) for u in urls])
    print(results)

asyncio.run(main())

aiohttp async rotation

aiohttp supports the proxy= kwarg directly on each session.get() call, making per-request rotation straightforward:

import asyncio
import random
import aiohttp

PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://your-proxy-3.sparkproxy.io:10002",
]

async def fetch(session: aiohttp.ClientSession, url: str) -> str:
    proxy = random.choice(PROXIES)
    async with session.get(url, proxy=proxy, timeout=aiohttp.ClientTimeout(total=10)) as resp:
        data = await resp.json(content_type=None)
        return data.get("origin", "error")

async def main():
    urls = ["https://httpbin.org/ip"] * 10

    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
        print(results)

asyncio.run(main())

For authenticated proxies with aiohttp, add proxy_auth:

auth = aiohttp.BasicAuth("username", "password")
async with session.get(url, proxy="http://your-proxy.sparkproxy.io:10000", proxy_auth=auth) as resp:
    ...

Scrapy Proxy Rotation Middleware

Scrapy uses a downloader middleware to assign a proxy to each Request before it goes out. The request.meta["proxy"] key is read by Scrapy's built-in HttpProxyMiddleware.

Custom rotation middleware (middlewares.py):

import random

class RotatingProxyMiddleware:
    PROXIES = [
        "http://your-proxy-1.sparkproxy.io:10000",
        "http://your-proxy-2.sparkproxy.io:10001",
        "http://your-proxy-3.sparkproxy.io:10002",
    ]

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

settings.py:

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.RotatingProxyMiddleware": 100,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 110,
}

The middleware priority number controls execution order. Lower numbers run first, the custom middleware runs before Scrapy's built-in HttpProxyMiddleware, which reads request.meta["proxy"] and applies it.

For per-domain rotation (different proxies for different sites):

class RotatingProxyMiddleware:
    PROXY_MAP = {
        "us.example.com": [
            "http://us-1.sparkproxy.io:10000",
            "http://us-2.sparkproxy.io:10001",
        ],
        "eu.example.com": [
            "http://eu-1.sparkproxy.io:10002",
        ],
    }
    DEFAULT = ["http://fallback.sparkproxy.io:10003"]

    def process_request(self, request, spider):
        domain = request.url.split("/")[2]
        pool   = self.PROXY_MAP.get(domain, self.DEFAULT)
        request.meta["proxy"] = random.choice(pool)

Test Proxy Health Before Use

Adding a proxy to your rotation pool without validating it first causes silent failures. Run a health check before every pool refresh:

import concurrent.futures
import requests

def check_proxy(proxy_url: str, test_url: str = "https://httpbin.org/ip", timeout: int = 8) -> dict:
    try:
        resp = requests.get(
            test_url,
            proxies={"http": proxy_url, "https": proxy_url},
            timeout=timeout,
        )
        resp.raise_for_status()
        return {
            "proxy":  proxy_url,
            "status": "ok",
            "exit_ip": resp.json().get("origin"),
            "latency_ms": int(resp.elapsed.total_seconds() * 1000),
        }
    except requests.exceptions.RequestException as exc:
        return {"proxy": proxy_url, "status": "failed", "error": str(exc)}


def filter_healthy(proxies: list[str], workers: int = 10) -> list[str]:
    """Return only working proxies from the list, tested in parallel."""
    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
        results = list(executor.map(check_proxy, proxies))

    healthy = [r["proxy"] for r in results if r["status"] == "ok"]
    print(f"{len(healthy)}/{len(proxies)} proxies healthy")
    return healthy


# Before building your pool, filter to healthy proxies only
RAW_PROXIES = [
    "http://your-proxy-1.sparkproxy.io:10000",
    "http://your-proxy-2.sparkproxy.io:10001",
    "http://bad-proxy.example.com:9999",
]

ACTIVE_PROXIES = filter_healthy(RAW_PROXIES)

Run filter_healthy() on pool initialization and periodically (every 5, 10 minutes for long-running scrapers) to evict dead proxies and re-admit recovered ones.


Common Rotation Errors and Fixes

Error / SymptomCauseFix
Same proxy hits all requests in multi-threaded code`next(cycle_iter)` without lockWrap `next()` in `threading.Lock` or use `queue.Queue`
`ProxyError: Cannot connect to proxy`Proxy down or network unreachableFilter pool with `filter_healthy()` before use; add retry logic
`407 Proxy Authentication Required`Credentials wrong or IP not whitelistedConfirm credentials or add machine IP to SparkProxy dashboard
`ConnectionError` only on HTTPS URLsProxy doesn't support HTTP CONNECT tunnelingVerify proxy supports HTTPS traffic; switch to a datacenter proxy with CONNECT support
`random.choices()` returns `TypeError``weights` list length doesn't match `population` listEnsure `len(PROXIES) == len(WEIGHTS)`
Pool exhausted: `queue.Empty`All proxies evicted due to failures, pool is emptyLower `max_failures` threshold or replenish pool from SparkProxy API
Scrapy: proxy set but requests go direct`HttpProxyMiddleware` disabled or priority conflictEnsure `HttpProxyMiddleware` is at a higher number (runs after) than your custom middleware
aiohttp: `ValueError: proxy should be str`Passed `None` or non-string proxy URLEnsure `random.choice(PROXIES)` returns a string; check `PROXIES` list is non-empty

Frequently asked questions

Use per-request rotation for stateless scraping (public pages, APIs with no login). Use per-session rotation when scraping requires authentication, create a requests.Session bound to one proxy, complete the full multi-step flow, then release that proxy. Switching proxies mid-session on a logged-in site usually triggers a security challenge.

A rule of thumb: one proxy per 5, 10 requests per minute per domain. For 1,000 requests per minute to a single domain, start with 100, 200 proxies. Datacenter proxies from SparkProxy support higher concurrency per IP than residential proxies.

No. asyncio runs on a single thread, no two coroutines execute simultaneously. next(cycle_iter) without a lock is safe in async code. Locks are only needed for true multi-threading (concurrent.futures.ThreadPoolExecutor, threading.Thread).

Yes. Pass the same proxy URL for both the "http" and "https" keys in the proxies dict: {"http": proxy_url, "https": proxy_url}. The proxy uses HTTP CONNECT tunneling for HTTPS traffic, the proxy URL scheme itself is http://, not https://. This is not a typo and is the correct configuration for datacenter proxies.

random.choice(seq) picks one item uniformly at random. random.choices(seq, weights=..., k=1) picks one item with weighted probability. Use random.choice for equally reliable proxies; use random.choices when you want to favor faster or more reliable proxies in the pool.

Treat a CAPTCHA response (status 200 with CAPTCHA HTML, or status 403) as a soft failure. Track CAPTCHA rate per proxy and reduce that proxy's weight in weighted rotation. After a configurable threshold (e.g., 3 CAPTCHAs in 10 requests), evict the proxy from the active pool and re-test it later.


Limited-time ยท 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon โ€” claim it before it's gone

Claim Discount

About the Author

SparkProxy Technical Team, The SparkProxy engineering team builds and maintains global datacenter and residential proxy infrastructure. This guide reflects proxy rotation patterns tested with Python 3.11+, requests 2.32+, httpx 0.27+, aiohttp 3.10+, and Scrapy 2.12+.

Citations: Python docs, itertools.cycle ยท urllib3 docs, Retry

Keep reading

Related articles