The undetected_chromedriver Python package, uc for short, patches ChromeDriver to help Selenium avoid bot detection. In our tests, uc 3.5.5 suppressed 2 common ChromeDriver signals: navigator.webdriver and the injected cdc_ variables.
When we tested, the newest PyPI release was 3.5.5, from February 2024. It needed 3 setup fixes on an Apple Silicon Mac before it could run. It loaded a bot-protected site on that Mac but got a challenge page in Linux containers on the same network.
This guide shows you how to run uc in 2026, fix its common errors, and decide when to switch tools.

Key takeaways
- uc suppressed both ChromeDriver signals in our tests. On a bot-protected site, vanilla Selenium got a challenge page and uc got the real page, from the same Mac and network.
- Plan for up to 3 setup fixes. Install setuptools on Python 3.12+, match the ChromeDriver version to your installed Chrome, and, on Apple Silicon, use Rosetta 2 or a native ARM driver.
- Headless mode needs 2 extra launch flags. In plain headless mode, uc sent "HeadlessChrome" in its User-Agent header. Headless uc got the real page on the Mac desktop after we set a normal Chrome user agent and a full-size screen.
- Test on the machine that will run your scraper. In Linux containers on the same network, all 15 self-hosted browser runs got a challenge page, including setups that passed on the Mac desktop. ScrapingBee's API returned the real page from its own infrastructure in the same tests.
How undetected_chromedriver works
Undetected ChromeDriver changes 2 things that a web page can check in a Selenium session, and sites can still check other signals. People also search for it as "undetectable ChromeDriver".
What the patch changes
In a vanilla Selenium session, ChromeDriver launches Chrome with automation switches such as --enable-automation, which sets navigator.webdriver to true. uc starts Chrome as an ordinary process with a remote-debugging port. It then attaches ChromeDriver to that browser through the debuggerAddress capability, so ChromeDriver doesn't add its switches.
The second change is a byte patch. uc downloads ChromeDriver on each launch and rewrites the JavaScript block that creates the cdc_ variables. With debug logging on, uc printed the block it found in ChromeDriver 153 and its replacement:
found block:
{window.cdc_adoQpoasnfa76pfcZLmcfl_Array = window.Array;window.cdc_adoQpoasnfa76pfcZLmcfl_Object = window.Object;window.cdc_adoQpoasnfa76pfcZLmcfl_Promise = window.Promise;window.cdc_adoQpoasnfa76pfcZLmcfl_Proxy = window.Proxy;window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol = window.Symbol;window.cdc_adoQpoasnfa76pfcZLmcfl_JSON = window.JSON;window.cdc_adoQpoasnfa76pfcZLmcfl_Window = window.Window;}
replacing with:
{console.log("undetected chromedriver 1337!")}
uc pads the replacement with spaces to the original length. Because the replacement is a console.log() call, the pages it drives log undetected chromedriver 1337! to the console. If you see that line in DevTools, the patch is active. The Console tab showed it after a single driver.get() call to example.com:

DevTools labels a script that has no URL as VM plus a number. VM3:1 is line 1 of the script that ChromeDriver injected.
Side by side with a vanilla Selenium session, the 2 changes look like this:

Both signals come from ChromeDriver, so uc stops ChromeDriver from launching Chrome and patches the ChromeDriver script that creates the cdc_ variables.
We loaded the same local test page in plain Chrome, vanilla Selenium 4.49, and uc 3.5.5 on Chrome 153:
| Check in the page | Plain Chrome | Vanilla Selenium | uc |
|---|---|---|---|
| navigator.webdriver | false | true | false |
| cdc_ variables on window | 0 | 7 | 0 |
| Runtime.enable probe fired | No | No | No |
The last row is about a signal that many 2024 and 2025 guides warned about. In the Runtime.enable probe, a page logs an Error with a stack getter that fires only while a Chrome DevTools Protocol (CDP) client is listening. Chrome 138 changed V8 to stop running page code while it builds previews of logged errors. The probe didn't fire in our test, even for a raw CDP client with the Runtime domain enabled.
Chrome 142 also fixed the screen coordinates on clicks sent through CDP, another known automation signal. Each fix removes one check, and CDP can still be detected in other ways. Castle sells bot detection and says it still flags CDP automation through signals it doesn't publish. So treat the probe result as one fixed signal.
The limits of its protection
In our tests, the patch suppressed both ChromeDriver signals in the browser. A site can also check other signals, which you still need to manage:
- Your IP address. The project README warns that uc doesn't hide your IP and that runs from datacenter addresses will likely fail. Our guide to scraping without getting blocked explains IP reputation in detail.
- The machine it runs on. A script that loaded a bot-protected page on a Mac desktop was challenged when it ran in Linux containers on the same network. An r/webscraping user reported a similar difference on an Akamai-protected site. Their code worked from a home PC but failed 90 to 100% of the time from GitHub Actions, whose runners use datacenter IPs. This does not identify the cause of our container failures, but it shows that the execution environment can materially change the result.
- Headless markers. Two launch flags changed the 2 headless markers we observed with headless=True: the HeadlessChrome user-agent token and the 800x600 screen size.
- Behavior and CAPTCHAs. Your code handles mouse movement and request rate, and Turnstile, the Cloudflare CAPTCHA widget, needs its own solution. Cloudflare says it also checks pointer and keystroke patterns across a session.
Fingerprinting tools such as CreepJS check the machine and headless markers as well as the browser itself, so plan for them when you use uc. Our CreepJS bypass guide explains what it checks.
Does undetected_chromedriver still work in 2026?
In our tests, undetected_chromedriver still suppressed both ChromeDriver signals, and it loaded a bot-protected page on a Mac desktop.
Test results on a DataDome-protected page
In September 2026, G2 protected its review pages with DataDome, an anti-bot service whose challenge loaded from captcha-delivery.com. Our DataDome bypass guide explains the service itself. A plain curl request got a 403.
We loaded https://www.g2.com/products/anaconda/reviews from a single residential connection with uc, vanilla Selenium, and 2 alternatives to uc: nodriver and SeleniumBase. We counted a run as successful when it returned the expected G2 page title. This criterion does not verify every page element or interaction.
| Where it ran | Setup | Result |
|---|---|---|
| Mac desktop | Vanilla Selenium | Challenge, 2 of 2 |
| Mac desktop | uc, headful | Real page, 3 of 3 |
| Mac desktop | uc, headless=True | Challenge, 2 of 2 |
| Mac desktop | uc, headless with a normal Chrome user agent and a full-size screen | Real page, 2 of 2 |
| Mac desktop | nodriver | Real page, 3 of 3 |
| Mac desktop | SeleniumBase Pure CDP Mode | Real page, 3 of 3 |
| Linux container | uc, headless=True | Challenge, 2 of 2 |
| Linux container | uc, headless with a normal Chrome user agent and a full-size screen | Challenge, 2 of 2 |
| Linux container | uc headful under Xvfb, x86_64 and arm64 | Challenge, 5 of 5 |
| Linux container | nodriver headful under Xvfb | Challenge, 2 of 2 |
| Linux container | SeleniumBase UC Mode under Xvfb | Challenge, 2 of 2 |
| Linux container | SeleniumBase Pure CDP Mode under Xvfb | Challenge, 2 of 2 |
In the containers, the browser settings we tried didn't change the result, and nodriver and SeleniumBase got the same challenge.
These screenshots come from 3 of the runs in the table:

Both uc runs used the same start_chrome() helper.
We didn't isolate which part of the container setup DataDome reacted to, so these are the differences to check first on your own server. The containers ran without a GPU. WebGL didn't start under Xvfb, a virtual display for Linux servers. In an arm64 headless run, it fell back to SwiftShader, a software renderer.
Test from the machine that will run your scraper.
Plan to maintain uc yourself
When we checked, the project's last release was 3.5.5, published to PyPI on February 17, 2024. Its GitHub repository had the issue tracker disabled and used Discussions instead. Other users posted the fixes in a thread about a driver mismatch on Chrome 142.
The one commit after 3.5.5, a Python 3.13 refactor from July 2025, was merged but not released to PyPI. That code crashed on launch in our test. The repository's CI test logs "PASSED CLOUDFLARE!" or "timeout" and exits successfully in both cases, so run your own check after each Chrome update.
Starting with version 153, Chrome releases a new milestone every 2 weeks instead of every 4. The 2-week release cycle creates twice as many milestone transitions at which an unmatched driver can fail. Your own check can be short: look for the undetected chromedriver 1337! console line, confirm navigator.webdriver is false, and load one real page from your target site.
Plan memory and bandwidth for each browser
On Linux, a single uc browser under Xvfb used 530 to 680 MB of proportional set size (PSS) while loading 3 ordinary pages. PSS apportions shared memory among the processes using it, so summing PSS largely avoids double-counting shared pages. Use that number to estimate how many browsers fit on one host.
With a new profile, Chrome also downloaded 40.8 MB from optimizationguide-pa.googleapis.com in its first minute. The page we loaded, example.com, used 5 to 10 KB. The download happened with uc and vanilla Selenium on macOS with Chrome 153, and with uc on Linux with Chrome 154. The download went through our proxy.
On Chrome 153 and 154, one launch flag stopped the download:
import undetected_chromedriver as uc
options = uc.ChromeOptions()
options.add_argument(
"--disable-features=OptimizationGuideModelDownloading,"
"OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationHints"
)
driver = uc.Chrome(options=options)
try:
driver.get("https://example.com/")
finally:
driver.quit()
Background traffic in the same minute fell to 0.4 MB on macOS and 0.9 MB on Linux with that flag. We also tested 2 other options, disabling only OptimizationGuideModelDownloading and adding --disable-background-networking, and the download continued with both. If your proxy charges per GB, add the flag before your first production run.
Install undetected_chromedriver on Python 3.12 and later
Install the package together with setuptools:
pip install undetected-chromedriver setuptools
Version 3.5.5 imports distutils in patcher.py, and Python 3.12 removed distutils from the standard library. setuptools provides a replacement module with the same name, so the import worked again on 3.12, 3.13, and 3.14 in our tests.
Check the install from the interpreter your scraper uses:
python -c "import undetected_chromedriver as uc; print(uc.__version__)"
A working install prints the version number:
3.5.5
Install from PyPI, even though the README also lists the GitHub master branch. That branch removes the distutils import, but uc.Chrome() then raised AttributeError: 'Version' object has no attribute 'version' in our test.
Launch undetected_chromedriver with a matching driver
With no arguments, uc gets the current Stable version number from Chrome for Testing (CfT), the Google site that publishes Chrome builds for automation. Then it downloads the ChromeDriver for that version. It doesn't check which Chrome version you have installed. CfT listed 154 as Stable when we tested, while our Mac ran Chrome 153, so the driver and the browser didn't match.
This helper reads your installed version, passes it as version_main, and sets the browser path so uc doesn't guess which browser to use. Save it as uc_setup.py, because the other snippets import from it:
import re
import subprocess
import sys
import undetected_chromedriver as uc
# Where Chrome lives on each OS. Change the path if yours differs.
CHROME_PATHS = {
"darwin": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"linux": "/usr/bin/google-chrome",
}
def chrome_major(chrome_path):
"""Read the installed Chrome major version, for example 154."""
try:
result = subprocess.run(
[chrome_path, "--version"], capture_output=True, text=True, check=True
)
except subprocess.CalledProcessError as error:
output = f"{error.stdout}{error.stderr}".strip()
raise RuntimeError(f"{chrome_path} --version failed: {output!r}") from error
output = f"{result.stdout}{result.stderr}".strip()
match = re.search(r"(\d+)\.\d+\.\d+\.\d+", output)
if match is None:
raise RuntimeError(f"No Chrome version in {chrome_path} output: {output!r}")
return int(match.group(1))
def start_chrome(**kwargs):
chrome_path = CHROME_PATHS[sys.platform]
return uc.Chrome(
browser_executable_path=chrome_path, # don't let uc guess
version_main=chrome_major(chrome_path), # match driver to browser
**kwargs,
)
if __name__ == "__main__":
driver = start_chrome()
try:
driver.get("https://books.toscrape.com/")
print(driver.title)
finally:
driver.quit()
In a Linux container with Google Chrome 154, xvfb-run python uc_setup.py loaded the page and printed its title:
All products | Books to Scrape - Sandbox
xvfb-run gives Chrome a virtual display. Without one, the same script failed on a Linux server with a chrome not reachable error, while headless mode needed no display.
The explicit browser path also stops uc from picking a browser at random. uc keeps candidate browser paths in a Python set, and set order can change between processes. With 3 candidate browsers on one machine, 30 separate runs picked them 8, 13, and 9 times. A pinned version_main can then be paired with the wrong browser.
Close the browser in a finally block, as the helper does. A with uc.Chrome() as driver: block doesn't quit the browser, because uc overrides the __exit__ context-manager method to disconnect ChromeDriver for 3 seconds and then reconnect. In our test, Chrome was still running after the block ended.
On Windows, the helper has no Chrome path, so call uc.Chrome() directly with browser_executable_path and version_main set to the major version at chrome://version. The helper stops with Bad CPU type in executable on an Apple Silicon Mac without Rosetta 2, until you install Rosetta 2 or use a native arm64 driver.
Keep a browser profile between runs
Pass user_data_dir to keep cookies and logins between runs. uc keeps that folder when the browser closes and deletes only the temporary profile it creates by default:
from pathlib import Path
from uc_setup import start_chrome
driver = start_chrome(user_data_dir=str(Path.home() / "uc-profile"))
try:
driver.get("https://books.toscrape.com/")
finally:
driver.quit()
In our test, a cookie with an expiry date persisted after a restart, and a session cookie didn't, as in normal Chrome. A reused profile also keeps the model files from optimizationguide-pa.googleapis.com. Chrome downloaded 40.8 MB from that host on the first launch and 0.2 MB on the second.
Use a dedicated folder, not your everyday Chrome profile. Since Chrome 136, Google Chrome refuses remote debugging on its default profile directory, and uc depends on remote debugging.
A second uc instance on a profile that was already open waited 60 seconds and then failed with a chrome not reachable error. Give each running browser its own folder.
Headless mode in 2026
With headless=True, uc sent HeadlessChrome/153.0.0.0 in the User-Agent header, and navigator.userAgent reported it in the page and in Web Workers. The window also reported a size of 1920x1080, while the screen reported only 800x600.
The uc source shows why the user agent keeps HeadlessChrome. uc adds --headless=new and contains a fix for the headless user agent, but the fix runs only when navigator.webdriver is true. A normal uc launch doesn't add --enable-automation, so navigator.webdriver stays false and the fix doesn't run.
With 2 launch flags, headless uc loaded the real G2 page in both desktop runs, while plain headless=True got the challenge both times. Our Linux containers got the challenge even with the flags. One flag sets a normal Chrome user agent, and the other gives headless Chrome a full-size screen:
import sys
import undetected_chromedriver as uc
from uc_setup import CHROME_PATHS, chrome_major, start_chrome
UA_PLATFORM = {
"darwin": "Macintosh; Intel Mac OS X 10_15_7",
"linux": "X11; Linux x86_64",
}
def start_headless_chrome():
major = chrome_major(CHROME_PATHS[sys.platform])
user_agent = (
f"Mozilla/5.0 ({UA_PLATFORM[sys.platform]}) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36"
)
options = uc.ChromeOptions()
options.add_argument(f"--user-agent={user_agent}")
options.add_argument("--screen-info={0,0 1920x1080}")
return start_chrome(options=options, headless=True)
driver = start_headless_chrome()
try:
driver.get("https://books.toscrape.com/")
print(driver.execute_script("return navigator.userAgent"))
print(driver.execute_script("return [screen.width, screen.height]"))
finally:
driver.quit()
On Linux with Chrome 154, the headless browser reported a normal Chrome user agent and a full-size screen:
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36
[1920, 1080]
Chrome's reduced user agent uses one fixed platform string per operating system, so ARM Linux also reports X11; Linux x86_64. The helper sends the same string.
On the Mac, we also loaded bot.sannysoft.com, a public bot-test page, with plain headless=True and with the 2 flags from start_headless_chrome(). Both setups passed its WebDriver checks. All 31 checks on the page passed with the 2 flags. Plain headless=True failed 3 of them, and each one was caused by the HeadlessChrome user agent.
The top of the results table shows the User Agent row for both setups:

The User Agent test only checks navigator.userAgent for HeadlessChrome, so the screen size doesn't affect it.
Set the user agent with the --user-agent flag, not with the CDP command Network.setUserAgentOverride. The CDP override emptied navigator.userAgentData.brands in our test, while plain Chrome reported 3 brands. The selenium-stealth package uses the same override. When we checked, its brands list was also empty, and all 7 cdc_ variables were still present.
Our G2 runs changed the user agent and the screen size together, so they don't show which of the 2 mattered to DataDome. A June 2026 arXiv preprint based on 10,000 sites suggests that request headers are an important signal.
The preprint found that headless Chromium was soft-blocked on 15% of sites, compared with 7% for other setups. It defines a soft block as a 403, 429, or 503 response. Of the blocks that affected only headless Chromium, request headers alone caused 75%.
In our Linux containers, headful Chrome under Xvfb got the challenge too. Test headless mode on the host that will run the job before you rely on it.
Use proxies with undetected_chromedriver
uc keeps the Chrome proxy flag unchanged, so unauthenticated proxies behave as they do in plain Chrome. For proxies that need a username and password, use a local forwarding proxy, because the usual uc setups failed on Chrome 153.
Unauthenticated HTTP and SOCKS5 proxies
Chrome gets its language and timezone from your machine, not from the proxy's location. This setup routes traffic through the proxy and sets the language and timezone to match a US exit IP:
import undetected_chromedriver as uc
from uc_setup import start_chrome
options = uc.ChromeOptions()
options.add_argument("--proxy-server=http://203.0.113.10:8080") # or socks5://
options.add_experimental_option("prefs", {"intl.accept_languages": "en-US,en"})
driver = start_chrome(options=options)
try:
driver.execute_cdp_cmd(
"Emulation.setTimezoneOverride", {"timezoneId": "America/New_York"}
)
driver.get("https://example.com/")
finally:
driver.quit()
Replace the address with your proxy's. The preference changed navigator.language and the Accept-Language header to en-US in our test, and the timezone override persisted after navigation. The snippet uses the preference because the --lang=en-US flag didn't change navigator.language on macOS. For rotation, set the flag to a provider gateway that rotates exit IPs, as in our rotating proxy setup for Selenium.
Authenticated proxies without extensions
We tested the usual methods for proxy authentication on Chrome 153:
- Credentials in the flag. --proxy-server=http://user:pass@host:port made Chrome show ERR_NO_SUPPORTED_PROXIES. driver.get() returned without an error, so a scraper would parse the error page.
- An auth extension. Google Chrome has ignored the --load-extension switch since version 137, and the feature flag that used to restore it had no effect on 153. The Debian Chromium build still loaded our test extension.
- The Selenium BiDi auth handler. With uc, driver.network.add_auth_handler() failed with Timed out receiving message from renderer.
With credentials in the flag, driver.title returned only the host name for https://books.toscrape.com/, and Chrome rendered its standard "This site can't be reached" page:

The page suggests the site is down. The error code under the message shows that the proxy flag is the cause.
A local forwarding proxy avoids all 3 problems. It accepts the browser's connection without credentials, adds them when it forwards the request to your provider, and tunnels HTTPS without decrypting it. Provided the upstream proxy does not intercept TLS, the target sees the browser's end-to-end TLS handshake. Each request takes this path through both proxies:

For HTTPS pages, the site sees the upstream proxy's exit IP and, barring TLS interception, the browser's TLS handshake.
Start proxy.py with its pool plugin, which adds the credentials to each request it forwards:
pip install proxy.py
proxy --hostname 127.0.0.1 --port 8899 \
--plugins proxy.plugin.ProxyPoolPlugin \
--proxy-pool USERNAME:PASSWORD@proxy.example.com:8000
Then configure uc to use the forwarder, which accepts only local connections, so other machines can't use your credentials through it:
import undetected_chromedriver as uc
from uc_setup import start_chrome
options = uc.ChromeOptions()
options.add_argument("--proxy-server=http://127.0.0.1:8899")
driver = start_chrome(options=options)
try:
driver.get("https://example.com/")
finally:
driver.quit()
With an upstream proxy that required a password, the page loaded through the forwarder, while the same credentials in --proxy-server failed.
Common undetected_chromedriver errors and fixes
These are the errors we reproduced with Chrome 153 and 154, each with the exact text and the fix that worked.
ModuleNotFoundError: No module named 'distutils'
Without setuptools, the import fails on Python 3.12 and later:
File ".../undetected_chromedriver/patcher.py", line 4, in <module>
from distutils.version import LooseVersion
ModuleNotFoundError: No module named 'distutils'
uc 3.5.5 imports distutils, which Python 3.12 removed. Run pip install setuptools in the same environment to restore it.
On our Mac, the error returned even with setuptools installed, in a virtual environment inside the iCloud-synced Desktop folder. setuptools restores distutils through a .pth file. Python 3.13 skipped that file because it had the hidden flag set. Running chflags -R nohidden .venv fixed it.
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N
With version_main unset, uc downloaded ChromeDriver 154 for our Chrome 153:
selenium.common.exceptions.SessionNotCreatedException: Message: session not created: cannot connect to chrome at 127.0.0.1:62228
from session not created: This version of ChromeDriver only supports Chrome version 154
Current browser version is 153.0.8010.53
uc downloads the ChromeDriver for the current Stable channel when version_main isn't set. If your browser runs a different major version, the launch stops. Pass your installed major version, as start_chrome() in uc_setup.py does. A hard-coded version_main=153 also works until your Chrome updates.
cannot connect to chrome at 127.0.0.1
This prefix appears when ChromeDriver can't attach to the browser that uc started, and the text after from shows the cause. A version mismatch is one cause. When a second uc instance opened a profile that another browser was using, the error was:
selenium.common.exceptions.SessionNotCreatedException: Message: session not created: cannot connect to chrome at 127.0.0.1:52687 from chrome not reachable
Give each browser its own user_data_dir, and match the driver to the browser with version_main. On a Linux server without a display, headful Chrome failed with the same chrome not reachable text. Run it under xvfb-run, or pass headless=True.
OSError: [Errno 86] Bad CPU type in executable
uc downloads the Intel (mac-x64) ChromeDriver on Apple Silicon Macs. The 2023 commit that hard-coded it says "Apparently the x64 version works on ARM devices with Mac", which is true when Rosetta 2 is installed. Without Rosetta, the launch stops with this error:
OSError: [Errno 86] Bad CPU type in executable: '/Users/you/Library/Application Support/undetected_chromedriver/undetected_chromedriver'
uc and SeleniumBase UC Mode both rely on Rosetta 2 to run the Intel driver on Apple Silicon. The command softwareupdate --install-rosetta installs it. Apple keeps Rosetta generally available on macOS 27 and earlier, so a native arm64 driver is the longer-term fix.
A native arm64 driver needs one extra step, because the uc byte patch invalidates the driver's ad-hoc code signature. macOS killed the patched file on launch with exit code 137, and re-signing it after the patch fixed that in our test. This script patches and re-signs a native arm64 driver for your Chrome version. Save it as arm64_driver.py next to uc_setup.py, because it imports from that file:
import io
import json
import os
import subprocess
import urllib.request
import zipfile
from pathlib import Path
from undetected_chromedriver.patcher import Patcher
from uc_setup import CHROME_PATHS, chrome_major, start_chrome
CFT = (
"https://googlechromelabs.github.io/chrome-for-testing/"
"latest-versions-per-milestone-with-downloads.json"
)
def driver_ready(path, major):
"""Check that a driver runs, matches Chrome, and is patched and signed."""
if not os.access(path, os.X_OK):
return False
try:
version = subprocess.run(
[str(path), "--version"],
capture_output=True, text=True, check=True, timeout=10,
).stdout
subprocess.run(
["codesign", "--verify", str(path)], capture_output=True, check=True
)
except (OSError, subprocess.SubprocessError):
return False
patched = Patcher(executable_path=str(path)).is_binary_patched()
return version.startswith(f"ChromeDriver {major}.") and patched
def native_arm64_driver():
"""Download, patch, and re-sign a native mac-arm64 ChromeDriver."""
major = str(chrome_major(CHROME_PATHS["darwin"]))
path = Path(f"chromedriver-arm64-{major}").resolve()
if driver_ready(path, major):
return str(path)
builds = json.load(urllib.request.urlopen(CFT))["milestones"][major]
url = next(
d["url"]
for d in builds["downloads"]["chromedriver"]
if d["platform"] == "mac-arm64"
)
archive = zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(url).read()))
temp = path.with_name(path.name + ".tmp")
temp.write_bytes(archive.read("chromedriver-mac-arm64/chromedriver"))
temp.chmod(0o755)
Patcher(executable_path=str(temp)).auto() # uc's own byte patch
subprocess.run(["codesign", "--force", "--sign", "-", str(temp)], check=True)
if not driver_ready(temp, major):
raise RuntimeError(f"Driver failed the patch or signature check: {temp}")
temp.replace(path) # only a checked driver reaches the cache
return str(path)
if __name__ == "__main__":
driver = start_chrome(driver_executable_path=native_arm64_driver())
try:
driver.get("https://books.toscrape.com/")
print(driver.title)
finally:
driver.quit()
uc accepts the pre-patched file and leaves it unchanged. The script caches one driver per Chrome major version and checks it before each reuse. In your own scraper, import native_arm64_driver and pass its result to start_chrome() as driver_executable_path.
On ARM Linux, the uc download is an x86 binary. The driver process exited unexpectedly with status code 255. Pass the distribution's own driver instead, such as /usr/bin/chromedriver on Debian, as driver_executable_path. The Debian Chromium build doesn't include the "Google Chrome" brand in its Client Hints, and a site can detect that the brand is missing.
TypeError: Binary Location Must be a String
uc raised this error in a Linux container without a browser:
File ".../undetected_chromedriver/__init__.py", line 372, in __init__
options.binary_location = (
File ".../selenium/webdriver/chromium/options.py", line 52, in binary_location
raise TypeError(self.BINARY_LOCATION_ERROR)
TypeError: Binary Location Must be a String
uc searches your PATH for a few fixed browser names, such as google-chrome and chromium. It also checks 2 app paths on macOS and a few standard install folders on Windows. When none exists, it passes None to Selenium, whose setter raises this error before the clearer uc message can appear. Pass browser_executable_path, as start_chrome() does.
InvalidArgumentException: unrecognized chrome option: excludeSwitches
Selenium stealth snippets that set the excludeSwitches and useAutomationExtension experimental options fail with uc:
selenium.common.exceptions.InvalidArgumentException: Message: invalid argument: cannot parse capability: goog:chromeOptions from invalid argument: unrecognized chrome option: excludeSwitches
ChromeDriver rejects excludeSwitches and useAutomationExtension when it attaches to a running browser, which is how uc works. Delete the 2 lines that set them, because uc doesn't add the enable-automation switch they were meant to remove.
Alternatives to undetected_chromedriver
Choose a replacement by what you want to keep: your Selenium-style code or a browser you run yourself. A hosted API can replace both. We ran 3 of these on the same G2 page:
| Tool | What it is | Our G2 result | Use it when |
|---|---|---|---|
| nodriver | Async CDP client from the uc author, no ChromeDriver | Real page on the Mac desktop, 3 of 3. Challenge in Linux containers, 2 of 2 | You're starting new Python code |
| SeleniumBase | Selenium framework with its own uc copy and CDP modes | Real page in Pure CDP Mode on the Mac desktop, 3 of 3. Challenge in Linux containers in UC Mode and Pure CDP Mode, 4 of 4 | You want Selenium-style APIs that were still maintained when we checked |
| ScrapingBee (our API) | Hosted scraping API with our browsers and proxies | Real page, by the same title check | Your jobs run on servers, or you want rendered HTML from an API |
| Patchright | Patched Playwright for Chromium | Not tested | Your code already uses Playwright |
| Camoufox | Firefox build with fingerprint patches | Not tested | A target site fingerprints Chromium specifically |
| selenium-stealth | JavaScript overrides for vanilla Selenium | Not tested on G2 | Your older code already uses it (last release November 2020, when we checked) |
Patchright and Camoufox are good options for teams that no longer use Selenium, and our Camoufox guide compares both.
nodriver, the same author's successor
nodriver drives Chrome over CDP directly, so there's no ChromeDriver binary to download, patch, or match to your browser. In an independent 2026 benchmark of 31 bot-protected targets, many behind Cloudflare, it was the only one of 7 tools with no blocked results. That benchmark didn't include uc. nodriver matched uc on G2: the real page on the Mac desktop and a challenge in Linux containers.
nodriver's AGPL-3.0 license is stricter than the uc GPL-3.0 license. Review the AGPL-3.0 terms before you use a modified copy in a hosted product.
Install nodriver with pip install nodriver. It can connect to a browser that uc started, so you can migrate one step at a time.
The nodriver function create_from_undetected_chromedriver() has a dangerous interaction with uc 3.5.5: it sets driver.browser_pid to -1, and the uc cleanup later calls os.kill(-1, SIGTERM). On macOS and Linux, that signal goes to every process your user owns, and it closed our whole desktop session. The 2 lines after the create_from_undetected_chromedriver() call stop that signal and a cleanup error:
import asyncio
import nodriver
from uc_setup import start_chrome
async def main():
driver = start_chrome()
browser = None
try:
driver.get("https://example.com/") # your existing Selenium steps
browser = await nodriver.util.create_from_undetected_chromedriver(driver)
driver.browser_pid = None # stop uc's cleanup from calling os.kill(-1, ...)
driver.keep_user_data_dir = True # skip a cleanup error at exit
tab = await browser.get("https://books.toscrape.com/")
await tab.wait_for("h1") # wait until the page has rendered
print(await tab.evaluate("document.title"))
finally:
if browser is None:
driver.quit() # the handoff never happened
else:
# stop() alone leaves a browser that nodriver adopted running
await browser.main_tab.send(nodriver.cdp.browser.close())
browser.stop()
asyncio.run(main())
In an isolated container, nodriver loaded the second page, and an unrelated test process that we started was still running. The same code without those 2 lines killed that test process. zendriver, a community fork of nodriver, appeared in a 2025 r/webscraping thread on uc alternatives, and we didn't test it. Our nodriver tutorial explains the library itself.
SeleniumBase Pure CDP Mode
SeleniumBase includes its own copy of uc as UC Mode, still maintained when we checked, plus CDP modes that drive Chrome without ChromeDriver. Install it with pip install seleniumbase. UC Mode refused to start on our Apple Silicon Mac with "Your Mac needs Rosetta 2 to use UC Mode!", the same limit uc has. Pure CDP Mode uses no driver and loaded the G2 page:
from seleniumbase import sb_cdp
sb = sb_cdp.Chrome("https://www.g2.com/products/anaconda/reviews")
try:
sb.sleep(8)
print(sb.get_title())
finally:
sb.driver.stop()
It printed the real page title, Anaconda Core Reviews 2026: Details, Pricing, & Features | G2, in 3 of 3 runs. In a Linux container under Xvfb, both modes got the DataDome challenge, and SeleniumBase needed python3-tk there before it would start. Pure CDP Mode requires rewriting your code for the SeleniumBase API, while UC Mode keeps the Selenium WebDriver interface you already use.
ScrapingBee for server-side scraping
The ScrapingBee HTML API takes a URL, renders the page in our browsers through our proxies, and returns the HTML. The IP address, the host machine, and the browsers then come from our infrastructure, so your scraper can run as a plain HTTP client.
Auto-Mode tries rendering and proxy setups in order of cost, stops at the first one that returns the page, and charges only for that setup. It uses the page options you pass, such as js_scenario for clicks, wait_for to wait for an element, block_resources to save bandwidth, and cookies. In our tests, Auto-Mode (mode=auto) returned the real G2 page, judged by the same page-title check as the other tools.
To try it, install the ScrapingBee Python SDK with pip install -U scrapingbee. Copy your key from the Your API Key card on your ScrapingBee dashboard and export it as SCRAPINGBEE_API_KEY, which keeps it out of your code. This request lets ScrapingBee pick the setup for the G2 page:
import html
import os
import re
from scrapingbee import ScrapingBeeClient
client = ScrapingBeeClient(api_key=os.environ["SCRAPINGBEE_API_KEY"])
response = client.html_api(
"https://www.g2.com/products/anaconda/reviews",
params={"mode": "auto"}, # let ScrapingBee pick the setup for this page
)
print(response.status_code)
title = re.search(r"<title[^>]*>(.*?)</title>", response.text, re.S)
print(html.unescape(title.group(1)).strip() if title else "no title")
The request printed the status code and the real page title:
200
Anaconda Core Reviews 2026: Details, Pricing, & Features | G2
Responses also include the page's own status code in the Spb-initial-status-code header, so you can confirm what the site returned. The Spb-auto-cost header shows the credits that Auto-Mode charged for the request. To set a spending limit for each request, add max_cost, which sets the maximum credits Auto-Mode can use.
Because we run the browsers, your servers save the memory and bandwidth that each browser would use. Each request uses API credits. Our documentation lists the credit cost of each setup, and our pricing page lists the plans and how to try ScrapingBee for free. You can also combine both: keep uc for the pages it loads and send the harder pages to our API.
Final thoughts
In our tests, undetected_chromedriver suppressed both ChromeDriver signals, and on a Mac desktop, it loaded a DataDome-protected page that challenged vanilla Selenium. Plan to maintain uc 3.5.5 yourself: setuptools, a version_main that matches your Chrome after each 2-week release, and an Apple Silicon driver fix. Test on your Linux host before you trust a desktop result: the same script was challenged in Linux containers in every mode we tried.
Keep uc for Selenium code you already run, and use nodriver for new code. For server-side jobs, try ScrapingBee for free on your hardest URLs before you build more browser infrastructure.
Frequently asked questions
Is undetected chromedriver still maintained?
Not actively. When we tested, the newest PyPI release was 3.5.5, from February 2024, and the GitHub repository had its issue tracker disabled. It ran on Chrome 153 and 154 with setuptools and a version_main matched to the installed Chrome, plus a patched native driver on ARM. Plan to maintain it yourself.
Does undetected chromedriver work with Cloudflare?
It depends on the site. In our tests, uc suppressed both ChromeDriver signals, but sites can also check IP reputation and request headers, and Cloudflare says it checks behavior. uc also doesn't solve Turnstile widgets. We tested DataDome, not Cloudflare, so test from the machine that will run your scraper.
Does undetected chromedriver work in headless mode?
It runs, and 2 launch flags change its main headless markers. With plain headless=True, uc sent HeadlessChrome in its User-Agent header, and a DataDome-protected G2 page challenged it in both runs. uc loaded the page on a Mac with a normal Chrome --user-agent and a full-size --screen-info, but not in a Linux container.
How is Selenium detected?
Common checks read navigator.webdriver, which ChromeDriver sets to true, and the cdc_ variables it adds to pages. ChromeDriver 153 adds 7 of them. Sites can also check IP reputation, headless signals, and behavior. On our Mac, vanilla Selenium got a DataDome challenge on a page that undetected_chromedriver loaded.
Is selenium-stealth better than undetected chromedriver?
Not for suppressing the ChromeDriver signals. selenium-stealth patches JavaScript properties in a normal Selenium session, and on Chrome 153 it kept all 7 cdc_ variables. undetected_chromedriver launches Chrome itself and patches the driver. When we checked, selenium-stealth's last release was from November 2020.
Is nodriver better than undetected chromedriver?
For new code, yes. nodriver, from the uc author, drives Chrome over CDP without ChromeDriver. It matched uc on a DataDome-protected page: the real page on a Mac, a challenge in Linux containers. After create_from_undetected_chromedriver(), set driver.browser_pid = None, or uc may send SIGTERM to every process you own.

