TL;DR
- Proxy rotation in Python means picking a different proxy URL from a list on each request, not a special mode of the
requestslibrary.requestsonly knows how to use whichever proxy dict you hand it for that call; your code is what decides which proxy that is. - A
proxiesdict keyed by scheme ({"http": ..., "https": ...}) is the only formatrequestsaccepts, and it can be passed per-request or set once on aSession. Per-request is safer for rotation, because updatingsession.proxieson every loop iteration is easy to get wrong. - Environment variables silently override
session.proxies. Therequestsdocumentation warns thatHTTP_PROXY/HTTPS_PROXYin the environment take precedence over whatever you set on a session, and this article reproduces that override live so you can see it happen. itertools.cyclegives you round-robin rotation in three lines, but it has no idea when a proxy is dead. Production rotation needs a retry-and-skip loop around it, which this article builds and runs against a real proxy failure.- urllib3's
Retryclass retries the same connection; it does not rotate between different proxies. Confusing the two is a common reason rotation code "doesn't seem to be rotating" —Retryand proxy rotation solve different problems and you typically need both. - Rotating proxy URLs is independent of where those URLs come from. The same loop works whether the list is three IPs you hardcoded for testing or a large pool from a paid provider; the code in this article was verified against local test servers precisely so it doesn't depend on any one provider.
What "Rotating Proxies" Actually Means in a Script?
Rotating proxies means sending different requests through different proxy servers so that no single outbound IP handles every request. Nothing in Python or requests rotates anything automatically — requests.get(url, proxies=proxy_dict) sends exactly one request through exactly one proxy, and it's your loop that decides which proxy_dict to pass on the next call. That distinction matters because a lot of rotation bugs turn out to be "the loop never actually changed the dict" rather than a proxy problem. The rest of this article builds that loop up from a single hardcoded proxy to a thread-safe pool with automatic failover, running every example against real local servers so you can see the actual behavior rather than trusting a snippet.
Installing What You Need
You need the requests library, which pulls in urllib3 as a dependency and handles both plain HTTP proxies and, from requests 2.10+, SOCKS proxies if you also install the socks extra:
pip install requests pip install "requests[socks]" # only needed for socks5:// proxy URLs
No other package is required for the patterns in this article — rotation is a few dozen lines of standard library code (itertools, random, threading, concurrent.futures) plus requests itself.
Configuring a Single Proxy Before You Rotate Anything
Answer the heading directly: requests expects a dictionary with "http" and "https" keys, each pointing at a proxy URL, and you either pass that dictionary to an individual call or attach it to a Session. The requests documentation shows the per-request form as:
import requests proxies = { "http": "http://10.10.1.10:3128", "https": "http://10.10.1.10:1080", } requests.get("http://example.org", proxies=proxies)
and the per-Session form as session.proxies.update(proxies) followed by session.get(...). Credentials go directly in the URL as http://user:pass@host:port. The same documentation flags a gotcha worth knowing before you build anything on top of this: it states plainly that "values provided will be overwritten by environmental proxies," meaning an HTTP_PROXY or HTTPS_PROXY environment variable takes priority over whatever you set on session.proxies. That's real and easy to hit by accident — running the following on a machine (or CI runner) with HTTP_PROXY already exported reproduces it:
import os import requests os.environ["HTTP_PROXY"] = "http://127.0.0.1:8002" session = requests.Session() session.proxies.update({"http": "http://127.0.0.1:8001"}) resp = session.get("http://internal.test/", timeout=5) print(resp.json()["served_by"])
proxy-2
The session was told to use the proxy on port 8001; the environment variable won anyway, and every request in the session went through port 8002 instead. If your rotation code is a Session and it seems to be ignoring your proxy list, check your environment variables before you check your rotation logic.
Take a Quick Look
Testing rotation logic against three local servers proves the loop works, but it won't tell you how real proxies behave under load — Nstproxy's gateway gives you a large real IP pool to point the same code at once you're ready.
Basic Rotation With itertools.cycle
The simplest rotation loop cycles through a fixed list of proxy URLs and hands a new one to requests on every call. This example was run against three local HTTP servers started on ports 8001–8003 for this article, each one identifying itself in its response so the rotation is verifiable rather than assumed:
import itertools import requests PROXIES = [ "http://127.0.0.1:8001", "http://127.0.0.1:8002", "http://127.0.0.1:8003", ] proxy_cycle = itertools.cycle(PROXIES) for i in range(6): proxy = next(proxy_cycle) resp = requests.get( "http://internal.test/products", proxies={"http": proxy, "https": proxy}, timeout=5, ) print(f"request {i + 1}: sent via {proxy} -> {resp.json()['served_by']}")
Actual output from that run:
request 1: sent via http://127.0.0.1:8001 -> proxy-1 request 2: sent via http://127.0.0.1:8002 -> proxy-2 request 3: sent via http://127.0.0.1:8003 -> proxy-3 request 4: sent via http://127.0.0.1:8001 -> proxy-1 request 5: sent via http://127.0.0.1:8002 -> proxy-2 request 6: sent via http://127.0.0.1:8003 -> proxy-3
itertools.cycle is the whole trick: it's an infinite iterator that loops back to the start of the list forever, so next(proxy_cycle) always returns a proxy without you tracking an index. Swap PROXIES for real proxy URLs and the loop works exactly the same way — it has no idea whether the list came from three local test servers or a commercial pool, which is the point of testing it this way first.
Advanced Patterns: Failover and Concurrent Rotation
Round-robin rotation on its own doesn't answer the question every rotation script eventually has to: what happens when one of the proxies is down? The pattern below picks a random proxy from the pool, catches the specific exceptions requests raises for proxy and connection failures, and moves on to the next candidate instead of letting the whole request fail:
import random import requests def get_with_rotation(url, proxy_pool, max_attempts=4, timeout=3): pool = list(proxy_pool) random.shuffle(pool) last_error = None for attempt, proxy in enumerate(pool[:max_attempts], start=1): try: resp = requests.get( url, proxies={"http": proxy, "https": proxy}, timeout=timeout, ) resp.raise_for_status() return proxy, resp except (requests.exceptions.ProxyError, requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc: print(f"attempt {attempt}: {proxy} failed ({exc.__class__.__name__}), rotating") last_error = exc continue raise last_error
ProxyError is documented as a subclass of ConnectionError raised specifically for proxy-related failures, which is why it's caught alongside the more general ConnectionError and Timeout. Running this against a pool of four addresses — three real local servers and one port nothing is listening on — produced this on one run:
attempt 1: http://127.0.0.1:8004 failed (ProxyError), rotating succeeded via http://127.0.0.1:8003 -> proxy-3
The dead address failed immediately and the loop moved on without crashing the script. Because random.shuffle reorders the pool on every call, a different run can succeed on the first attempt if the shuffle happens to put a working proxy first — both outcomes are correct behavior for this pattern.
Rotating from multiple threads adds one more requirement: whatever picks "the next proxy" has to be safe to call from several threads at once. A shared itertools.cycle behind a threading.Lock, driven by ThreadPoolExecutor, keeps the round-robin order intact under concurrency:
import itertools import threading from concurrent.futures import ThreadPoolExecutor, as_completed import requests PROXIES = ["http://127.0.0.1:8001", "http://127.0.0.1:8002", "http://127.0.0.1:8003"] _lock = threading.Lock() _cycle = itertools.cycle(PROXIES) def next_proxy(): with _lock: return next(_cycle) def fetch(path): proxy = next_proxy() resp = requests.get(f"http://internal.test{path}", proxies={"http": proxy, "https": proxy}, timeout=5) return path, proxy, resp.json()["served_by"] paths = [f"/item/{i}" for i in range(9)] with ThreadPoolExecutor(max_workers=4) as pool: futures = [pool.submit(fetch, p) for p in paths] for future in as_completed(futures): print(future.result())
Nine requests across four worker threads still landed exactly three per proxy, in order, because the lock serializes access to the shared iterator even though the requests themselves run concurrently. ThreadPoolExecutor is part of the standard library's concurrent.futures module — no extra dependency needed for this pattern.
Honest Limits: What This Rotation Code Can't Fix
This code decides which proxy URL goes on each request; it has no opinion on where those URLs come from or whether they're any good. A list of three dead IPs will rotate through exactly as smoothly as a list of three healthy ones — the failover loop above only proves it can recover from some bad proxies, not that your specific list is usable.
Retry from urllib3 is not a substitute for the rotation logic in this article. Its own parameters — total, backoff_factor, status_forcelist — describe retrying the same request against the same connection with a backoff delay between attempts; nothing in urllib3.util.Retry switches to a different proxy. If you mount a Retry-configured HTTPAdapter on a session that has one proxy set, every retry still goes through that same proxy. Rotating to a different proxy on failure is what the get_with_rotation function above does instead, and the two techniques are meant to be combined, not chosen between.
None of the examples here handle proxy authentication rate limits, sticky-session semantics, or per-country routing — those are properties of whatever proxy service sits behind the URLs, not something itertools.cycle or a retry loop can add.
Troubleshooting Common Rotation Errors
requests.exceptions.ProxyError means the connection to the proxy itself failed — the proxy is down, the port is wrong, or a firewall is blocking it. This is what the dead port in the failover example above triggers.
requests.exceptions.ConnectionError without the more specific ProxyError subtype usually means the proxy accepted the connection but couldn't reach the destination site, which often shows up as a proxy nearing the end of its usable life if you're pulling from a rotating pool.
Rotation looks like it's not happening even though your loop looks right — check for an HTTP_PROXY or HTTPS_PROXY environment variable first, per the live reproduction earlier in this article; it silently overrides session.proxies and makes every request go through the same address regardless of what your loop selected.
Every request times out at the same timeout value instead of failing fast on dead proxies — lower the timeout argument specifically for health-checking a new pool, since a generous timeout meant for real requests will make a dead proxy hang for the full duration on every rotation attempt.
Pointing Rotation Code at a Real Proxy Pool
The loops in this article rotate through whatever list you give them, and swapping in a commercial gateway means changing the list, not the logic. Nstproxy is a proxy infrastructure provider offering residential, datacenter, static ISP, IPv6, and mobile IP pools, reachable over HTTP, HTTPS, or SOCKS5 through a single gateway host rather than a list of individual IPs you manage yourself. Its Residential Lite Proxies product page documents the connection pattern directly: a fixed gateway host and port, with the actual IP rotation happening behind that one address on the provider's side rather than in your Python list — see Nstproxy's documentation for the current host, port, and authentication parameters before wiring them into the examples above. That fits teams who want the rotation behavior from this article without maintaining and health-checking their own list of individual proxy IPs. The tradeoff is that you're trusting the provider's rotation behavior behind the gateway instead of controlling it directly the way the itertools.cycle and failover examples above do.
- One gateway host instead of a list to maintain — the code from this article's basic example collapses to a single proxy dict pointed at that one gateway address instead of a list of many; check the documentation link above for the exact current host and port before using it.
- 50M+ residential IPs across 200+ countries and regions — per the Residential Lite product page, which means the rotation happening behind the gateway draws from a much larger pool than most self-managed lists.
- HTTP, HTTPS, and SOCKS5 support — the same protocols the
requestsexamples in this article already use, so no changes to the request code itself. - Prepaid package billing — Nstproxy's published pricing is sold in metered packages rather than a subscription, which matters if your rotation script only runs occasionally rather than continuously.
If you're rotating proxies specifically to drive a headless browser rather than plain requests calls, configuring a proxy in Playwright covers the browser-context version of the same problem.
Conclusion
Rotating proxies in Python is a loop that changes one dictionary between requests, not a feature you install — everything in this article, from the three-line itertools.cycle version to the thread-safe failover pool, is built on that same {"http": ..., "https": ...} dict requests has always accepted. Start with the basic cycle to confirm your request code works at all, add the failover loop once you're pointing at proxies that can actually fail, and only reach for threads once single-request latency is your actual bottleneck.
FAQ
Q: Do I need a special library to rotate proxies in Python, or does requests handle it?
You don't need a special library — requests only accepts one proxy dict per call, and rotation is the loop in your own code that changes which dict gets passed on each request, as shown throughout this article.
Q: Why does my rotation code seem to always use the same proxy?
Check for an HTTP_PROXY or HTTPS_PROXY environment variable first, since requests' own documentation confirms it silently overrides whatever you set on session.proxies, which this article reproduces live.
Q: Does urllib3's Retry class rotate proxies for me?
No — Retry retries the same request against the same connection with a backoff delay, and does not switch to a different proxy; you need a rotation loop like the failover example in this article alongside it, not instead of it.
Q: Is it safe to rotate proxies from multiple threads at once?
It's safe as long as whatever selects "the next proxy" is protected by a lock, as in the threading.Lock-guarded itertools.cycle example above; an unprotected shared iterator accessed from multiple threads is the usual source of subtle rotation bugs under concurrency.
Q: What's the difference between rotating my own list of proxies and using a rotating gateway from a provider?
Rotating your own list means your code tracks which proxies are alive and picks between them, while a rotating gateway does that same job behind one fixed host and port on the provider's side, which is the pattern Nstproxy documents for its Residential Lite Proxies.



