🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Rotate User Agents for Web Scraping

Rotate user agents for web scraping in Python: build a current UA pool, pair each string with matching Sec-CH-UA and Accept-Language headers to avoid blocks.

S SparkProxy 7 19 min read
Share
How to Rotate User Agents for Web Scraping

Your scraper's loudest tell usually isn't the IP. It's the User-Agent string python-requests/2.32.5 that every default requests call ships to the server. To rotate user agents in a way that actually helps, you need more than a random string pulled from a stale list. You need a pool of current, real browser signatures, and each one has to travel with the other headers that browser really sends. This guide shows how to build that pool, rotate it per request or per session, keep the Sec-CH-UA client hints and Accept-Language consistent with the string you send, pair the user agent with a rotating proxy, and recognize the point where user-agent rotation stops helping and something else has to take over.

What Is a User Agent?

A user agent is a request header your HTTP client sends with every request. It identifies the software making the call: the browser, its version, the rendering engine, and the operating system. A real Chrome request on Windows sends something like this:

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36

Servers read that string to decide what to send back: a mobile layout, a desktop layout, a specific script bundle, or a block page. The string is trivial to set and trivial to fake, which is exactly why anti-bot systems treat it as a first-pass filter rather than a source of truth. Faking it well is table stakes; faking it badly is worse than not faking it at all.

Your default client gives you away immediately. requests sends python-requests/2.32.5. httpx sends python-httpx/0.28.1. aiohttp, curl, Scrapy, and Go's net/http all announce themselves by name. Any site that cares about automated traffic drops those on sight.


Why Sites Block Stale or Bot User Agents

Anti-bot systems flag user agents in three ways, and each one catches a different mistake.

Named automation clients. Anything containing python-requests, curl, Go-http-client, Scrapy, Java, or okhttp is a library default. These are the cheapest blocks a site can make.

Impossible or dead versions. Many scraping tutorials copy a user-agent list that was current three years ago. A request claiming Chrome/85.0 in 2026 is a giveaway, because that build was retired long ago and no real user is running it at volume. Some detection engines keep a rolling window of known-good version ranges and reject anything outside it. Scraping a fixed list from a blog post guarantees you drift out of that window within months.

Header sets that contradict the string. This is the one most guides miss. A user agent claiming Chrome 138 that arrives without any Sec-CH-UA client-hint headers is contradictory, because real Chrome 138 always sends them. A Safari user agent that arrives with Sec-CH-UA present is equally wrong, because Safari does not implement client hints at all. The mismatch is a stronger bot signal than a plain library default, since no honest browser produces it.

Rotating user agents is one layer in a larger defense. For the full picture on the other layers, see our guide on how to avoid getting your proxy blocked.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Building a Realistic User-Agent Pool

A good pool is small, current, and internally consistent. You do not need thousands of strings. You need a handful of real, recent browser signatures that you keep updated. The table below lists current desktop and mobile user agents as of mid-2026. Treat the version numbers as perishable and refresh them every few weeks.

Browser / PlatformExample User-Agent stringSends Sec-CH-UA?
Chrome / Windows 10-11`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36`Yes
Chrome / macOS`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36`Yes
Edge / Windows`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0`Yes
Firefox / Windows`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0`No
Safari / macOS`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15`No
Chrome / Android`Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36`Yes
Safari / iPhone`Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1`No

Two rules make this pool work. First, weight it toward what your target's real audience uses. If you scrape a US retail site, most visitors run Chrome on Windows and Chrome or Safari on mobile, so those should dominate the pool. A pool that is 40% Firefox looks nothing like real traffic. Second, keep the pool honest about client hints: mark which strings must carry Sec-CH-UA and which must not, because you will use that flag in the next sections.

About the fake-useragent library: it is convenient, but it pulls from a data set that can include rare, old, or oddly formatted strings, and a random draw can hand you a Chrome/91 on a bad day. A short curated list you control and update by hand is more predictable for production scraping. Use fake-useragent for quick tests, not for a job you need to stay unblocked for weeks.


How to Change the User Agent in Python

Before rotating anything, confirm what you send by default and how to override it. Set the header explicitly on the request or, better, on a session:

import requests

# What requests sends by default
print(requests.utils.default_headers()["User-Agent"])
# python-requests/2.32.5

CHROME_WIN = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/138.0.0.0 Safari/537.36"
)

# Per-request override
resp = requests.get("https://www.sparkproxy.io", headers={"User-Agent": CHROME_WIN}, timeout=10)

# Per-session override (applies to every request on the session)
session = requests.Session()
session.headers.update({"User-Agent": CHROME_WIN})
resp = session.get("https://www.sparkproxy.io", timeout=10)

Setting User-Agent on a Session is the pattern you want for a scraper, because it keeps the header consistent across the connection reuse that a session provides. In Scrapy, set USER_AGENT in settings.py or override it per request in Request(headers=...). In httpx, pass headers= to the Client. The mechanism is the same everywhere: one header, set once per identity.


Rotating User Agents Per Request

The simplest rotation picks a random user agent from the pool for each request. This is the pattern behind most searches for a random user agent python snippet:

import random
import requests

USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15",
]

def get(url: str) -> requests.Response:
    ua = random.choice(USER_AGENTS)
    return requests.get(url, headers={"User-Agent": ua}, timeout=10)

for _ in range(5):
    r = get("https://httpbin.org/user-agent")
    print(r.json()["user-agent"])

random.choice() is thread-safe in CPython, so this is safe to call from a ThreadPoolExecutor without a lock. That covers the mechanics. The problem is that this snippet sends only the User-Agent and nothing else, which is exactly the contradiction described earlier. A Chrome string with no client hints is a red flag. We fix that in the matching-headers section.


Random vs Per-Session Rotation

The bigger mistake is not which random function you use. It is rotating the user agent at the wrong boundary.

A real browser does not change its user agent between requests. It picks one identity when it launches and keeps it for the entire session: same UA, same TLS handshake, same cookie jar, for hours. If your scraper holds a login cookie or a session token and the user agent flips on every request, you have described a user whose browser mutates mid-session. Many anti-bot systems bind the user agent to the session cookie and flag exactly that.

The rule is simple:

Scraping patternRotation boundary
Stateless requests to public pages, no cookiesPer request is fine
Anything with a login, cart, or session cookiePer session: one UA for the life of the session
Multi-step flow (search then paginate then detail)Per session, tied to the same proxy
Fresh identity per worker or per proxyOne UA per worker, held until the proxy changes

For per-session rotation, assign the identity once when you build the session and never touch it again:

import random
import requests

def new_identity_session() -> requests.Session:
    ua = random.choice(USER_AGENTS)
    session = requests.Session()
    session.headers.update({"User-Agent": ua})
    return session

# One session = one consistent identity for the whole flow
session = new_identity_session()
session.get("https://www.sparkproxy.io/login", timeout=10)
session.get("https://www.sparkproxy.io/dashboard", timeout=10)  # same UA, same cookies

Pair the User Agent With Matching Headers

This is the section most user-agent rotation guides skip, and it is the one that decides whether your rotation helps or hurts. A user agent is not a standalone value. Modern Chromium browsers advertise their identity across a set of headers that must agree with the User-Agent string.

The critical set is User-Agent Client Hints, sent as Sec-CH-UA headers. Chrome, Edge, and other Chromium browsers send them on every request. Firefox and Safari do not send them at all. So your rules are:

  • Chromium user agent, you must add matching Sec-CH-UA, Sec-CH-UA-Mobile, and Sec-CH-UA-Platform.
  • Firefox or Safari user agent, you must not send any Sec-CH-UA headers.
  • Sec-CH-UA-Platform has to match the OS in the string. A Windows UA with "macOS" platform is a contradiction.
  • Sec-CH-UA-Mobile is ?1 for mobile strings and ?0 for desktop.

The clean way to enforce this is to store each user agent with its full header set, then rotate the whole bundle instead of a lone string:

import random
import requests

# Each profile is a complete, self-consistent identity.
PROFILES = [
    {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
        "sec-ch-ua": '"Google Chrome";v="138", "Chromium";v="138", "Not.A/Brand";v="24"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,"
                  "image/webp,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    },
    {
        # Firefox: NO sec-ch-ua headers, different Accept string
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) "
                      "Gecko/20100101 Firefox/141.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.5",
    },
    {
        # Safari: NO sec-ch-ua headers
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                      "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    },
]

def get(url: str) -> requests.Response:
    headers = random.choice(PROFILES).copy()
    return requests.get(url, headers=headers, timeout=10)

Notice the Accept-Language values differ between profiles, and the Accept string differs between Chrome and Firefox. Those are real, browser-specific defaults, and copying them exactly is what turns a suspicious request into an ordinary one. Accept-Language also has a geo dimension: if you route through a German exit IP, an en-US language header on a request that resolves a German page is a small inconsistency worth avoiding. Set the language to match the region you are presenting as.

Header order matters too, though requests limits how much control you have there. Real browsers send headers in a fixed, browser-specific order, and requests and urllib3 use their own order that differs from Chrome's. For casual targets this is invisible. For hard targets that fingerprint header order over HTTP/2, it is another mismatch. If you need to read more about which headers reveal a client and how proxies add their own, see proxy headers explained: X-Forwarded-For and more.


Rotate the User Agent With Each Proxy

User-agent rotation and proxy rotation solve different halves of the same problem. The proxy changes the IP the site sees; the user agent changes the client the site sees. Rotate them together so each new IP arrives with a fresh, consistent identity, and hold that pairing for the life of the session.

The anti-pattern is a single IP that cycles through ten user agents, or a single user agent spread across ten IPs. Both look mechanical. Bind one identity to one proxy:

import random
import requests

PROXIES = [
    "http://user-1:pass@gate.sparkproxy.io:10000",
    "http://user-2:pass@gate.sparkproxy.io:10001",
    "http://user-3:pass@gate.sparkproxy.io:10002",
]

def build_worker():
    """Pin one proxy to one browser identity for the whole session."""
    profile = random.choice(PROFILES)   # from the matching-headers section
    proxy = random.choice(PROXIES)
    session = requests.Session()
    session.headers.update(profile)
    session.proxies = {"http": proxy, "https": proxy}
    return session

worker = build_worker()
r = worker.get("https://httpbin.org/anything", timeout=15)
print(r.json()["headers"]["User-Agent"], "via", r.json()["origin"])

For the full set of proxy rotation strategies (round-robin, weighted, retry on failure, thread-safe pools), see how to rotate proxies in Python. Pair that proxy logic with the identity logic here so an IP change and a user-agent change always happen at the same boundary.


Rotating at Scale With aiohttp

For high concurrency, aiohttp lets you attach a fresh identity per request without blocking the event loop. Pass the profile headers and proxy on each session.get() call:

import asyncio
import random
import aiohttp

async def fetch(session: aiohttp.ClientSession, url: str) -> str:
    profile = random.choice(PROFILES)          # from the matching-headers section
    proxy = random.choice(PROXIES)
    async with session.get(
        url,
        headers=profile,
        proxy=proxy,
        timeout=aiohttp.ClientTimeout(total=20),
    ) as resp:
        data = await resp.json(content_type=None)
        return data["headers"]["User-Agent"]

async def main():
    urls = ["https://httpbin.org/anything"] * 20
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*(fetch(session, u) for u in urls))
    for ua in results:
        print(ua)

asyncio.run(main())

When your workload is stateless and per-request rotation is acceptable, this scales cleanly to hundreds of concurrent requests. When each task represents a logged-in session, build one ClientSession per identity instead of rotating inside a shared session. For the async patterns in depth, including connection limits and retries, see using proxies with Python requests, aiohttp, and async scraping.


Why User-Agent Rotation Alone Is Not Enough

Here is the hard truth that separates a scraper that survives from one that gets blocked in an hour: a serious anti-bot system does not need to read your user agent to know you are requests.

Before a single HTTP header is parsed, the server sees your TLS fingerprint. The order of cipher suites, the extensions, and the elliptic curves your client offers during the TLS handshake form a signature known as JA3 or JA4. Python's requests (through urllib3 and OpenSSL) produces a TLS fingerprint that is nothing like Chrome's. You can send a flawless Chrome 138 user agent with perfect client hints, and the handshake still says "this is a Python script." The user agent claims Chrome; the TLS says otherwise; the mismatch is decisive.

This is why user-agent rotation is necessary but not sufficient. To get past fingerprinting you need to match the TLS layer too. The common options:

  • curl_cffi: a Python library that impersonates real browser TLS fingerprints. Use it as a drop-in for requests when a target fingerprints the handshake.
  • Headless browsers (Playwright, Puppeteer, Selenium): they run a real browser engine, so the TLS, header order, and client hints are genuine. Heavier and slower, but the fingerprint surface is consistent by construction.
  • A scraping API: offload the entire fingerprint problem, TLS included, to a service that keeps it aligned for you.

The decision between running this stack yourself and paying an API to run it is a real trade-off, covered in web scraping API vs self-managed proxies.


Let SparkProxy's Scraping API Handle UA and Headers

If maintaining a current UA pool, matching client hints, Accept-Language by region, header order, and TLS fingerprints sounds like a lot to keep aligned, that is the point. SparkProxy's Scraping API sets all of it together on the server side, so the layers never contradict each other.

The base endpoint is https://scrape.sparkproxy.io/api/v1, authenticated with an X-API-Key header. To rotate the user agent automatically, set device to random, and the API selects a real desktop, mobile, or tablet profile with its matching headers and fingerprint:

import requests

API = "https://scrape.sparkproxy.io/api/v1"

resp = requests.get(
    API,
    params={
        "url": "https://www.sparkproxy.io",
        "render_js": "true",     # real headless render, genuine header set + TLS
        "device": "random",      # rotates a real UA + matching Sec-CH-UA per request
        "country_code": "US",    # geo exit; keeps Accept-Language regionally sensible
    },
    headers={"X-API-Key": "sk-YOUR_KEY"},
    timeout=60,
)
print(resp.status_code)
print(resp.text[:500])

When you need to pin a specific identity instead of rotating, send it explicitly. custom_ua overrides the user-agent string, and forward_headers merges your own headers on top of the browser defaults:

import requests

resp = requests.post(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "sk-YOUR_KEY", "Content-Type": "application/json"},
    json={
        "url": "https://www.sparkproxy.io/pricing",
        "render_js": True,
        "device": "desktop",
        "custom_ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                     "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
        "forward_headers": {"Accept-Language": "en-US,en;q=0.9"},
        "stealth": True,         # enhanced anti-detection, aligned TLS fingerprint
    },
    timeout=60,
)
print(resp.json())

Because render_js runs a real browser engine and stealth aligns the fingerprint, the user agent, the client hints, and the TLS handshake all describe the same browser. That consistency, not the rotation by itself, is what gets the response back.


Common Mistakes and Fixes

MistakeWhy it failsFix
Sending only `User-Agent`, no other headersReal browsers send `Accept`, `Accept-Language`, and (for Chromium) `Sec-CH-UA`Rotate a full header profile, not a lone string
Chrome UA with no `Sec-CH-UA` headersReal Chrome always sends client hints; absence is a contradictionAdd matching `sec-ch-ua`, `-mobile`, `-platform` for Chromium profiles
Firefox or Safari UA plus `Sec-CH-UA`Those browsers never send client hintsStrip client-hint headers from non-Chromium profiles
Stale versions like `Chrome/85`Retired builds fall outside known-good version rangesRefresh the pool every few weeks; verify versions are current
`Sec-CH-UA-Platform` disagrees with the OS in the UAWindows UA claiming `"macOS"` platform is impossibleKeep platform, OS token, and mobile flag in sync per profile
Rotating UA on every request of a logged-in sessionBrowsers do not change identity mid-sessionRotate per session; hold one UA for the session's life
One UA across many IPs, or one IP across many UAsNeither pattern resembles real trafficBind one identity to one proxy for the session
Perfect UA but default `requests` TLSJA3/JA4 fingerprint still says PythonUse `curl_cffi`, a headless browser, or a scraping API
`fake-useragent` random draw in productionCan serve rare or outdated stringsUse a curated, hand-updated list for jobs that must stay unblocked

Frequently asked questions

FAQ

There is no single best string. The best user agent is the one that matches your target's real audience and is current: usually the latest Chrome on Windows for most Western sites, since that is the most common real configuration. A good user agent list for scraping is short, weighted toward popular browsers, and refreshed every few weeks so versions stay current.

Store a curated list of current strings and call random.choice(USER_AGENTS). The fake-useragent library also produces a random user agent in Python, but its data set can include old or rare strings, so a hand-maintained list is more reliable for production. Always send the matching headers with the string, not the string alone.

Rotate per request only for stateless scraping with no cookies. For anything with a login or session cookie, keep one user agent for the entire session, because real browsers never change their identity mid-session. Tie the rotation boundary to the proxy: a new IP and a new user agent should appear together.

No. User-agent rotation defeats simple string checks, but modern anti-bot systems read your TLS fingerprint (JA3/JA4) before any header, and default Python clients look nothing like a real browser there. You need matching headers plus a matched TLS fingerprint through curl_cffi, a headless browser, or a scraping API.

In requests, pass headers={"User-Agent": ...} on a request, or set session.headers.update(...) on a Session. In Scrapy, set USER_AGENT in settings.py or override it per request with Request(headers={"User-Agent": ...}). For rotation, a downloader middleware that assigns a header profile per request is the standard pattern.

The user agent itself is location-neutral, but the Accept-Language header that travels with it should match the region of your exit IP. Routing through a German proxy while sending en-US is a small inconsistency some sites check, so set Accept-Language to fit the geo you are presenting.


Limited-time · 50% off

Get 50% off your first month

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, plus a managed Scraping API that handles user-agent rotation, client hints, and TLS fingerprinting automatically. This guide reflects behavior tested with Python 3.12+, requests 2.32+, aiohttp 3.11+, and current browser builds as of mid-2026 (Chrome 138, Firefox 141, Safari 18.5). Keep your own user-agent pool current, because the version numbers here will age.

Citations: MDN, User-Agent header · MDN, User-Agent Client Hints (Sec-CH-UA) · SparkProxy Scraping API docs

Keep reading

Related articles

How to Scrape Alibaba Product Data

How to Scrape Alibaba Product Data

Scrape Alibaba product data at B2B depth: parse ladder pricing, normalize MOQ units, read supplier trust badges, and pivot rows from products to suppliers.

SparkProxy·Guides
How to Detect When Your Scraper Is Blocked

How to Detect When Your Scraper Is Blocked

Detect when your scraper is blocked, including silent HTTP 200 soft blocks: baselines, selector contracts, canary URLs, and per-IP success rates.

SparkProxy·Guides