๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Comparisons

SeleniumBase UC Mode vs Undetected-Chromedriver

SeleniumBase UC Mode vs undetected-chromedriver: both patch the same driver binary. We measured the scaffolding each installs, then coded one job twice.

S SparkProxy 6 31 min read
Share

SeleniumBase UC Mode vs undetected-chromedriver is not a stealth question. Both patch the same ChromeDriver executable and both decouple that driver from Chrome at the moments a page is looking. The difference is what else lands in your site-packages: one install brings 21 distributions, the other brings 60, including pytest, pytest-xdist, pytest-html, pytest-rerunfailures, behave and a 123-flag command-line interface. Either that runner is the thing you were missing, or it is 39 extra packages you now own.

Everything below was read or run on 9 October 2026 against the projects' own source, their PyPI metadata and the GitHub API. Package counts and footprints come from two clean CPython 3.13.5 virtualenvs built that day. Where we did not test something, the sentence says so.

This post deliberately does not rank the two on detection outcomes. For the "which stealth layer should I be on at all" question, read undetected-chromedriver vs Patchright vs Camoufox, which compares patch layers rather than packaging.

What each pip install actually delivers

Start with the thing nobody measures. Two fresh venvs on CPython 3.13.5, one command each, nothing else installed.

Measured 2026-10-09, CPython 3.13.5`undetected-chromedriver``seleniumbase`
Version resolved3.5.54.55.2
Distributions in `pip freeze`2160
Files under `site-packages`2,5125,786
Size of `site-packages`55.9 MB95.2 MB
Console-script executables addednone of its own`sbase`, `seleniumbase`, `seleniumbase-mcp`, plus `pytest`, `behave`, `pynose`
Declared install requires457
`requires_python`not declared`>=3.10`
LicenseGPL-3.0MIT

The 39-package gap is almost entirely test machinery. SeleniumBase pulls pytest, pytest-xdist, pytest-html, pytest-metadata, pytest-ordering, pytest-rerunfailures, pynose, behave, parameterized, rich, Jinja2, MarkupSafe, markdown-it-py, pygments, psutil, filelock, fasteners, PyOTP, sbvirtualdisplay, setuptools and wheel. The scraping-relevant parts of the two trees look nearly identical: both end up on selenium, requests, urllib3, trio, websockets and websocket-client.

Now the part that surprises people who assume the name "UC Mode" implies a dependency. It does not. pip show seleniumbase lists 57 requirements and undetected-chromedriver is not one of them. SeleniumBase carries its own fork inside the package, at seleniumbase/undetected/, as seven modules: __init__.py, patcher.py, cdp.py, dprocess.py, options.py, reactor.py, webelement.py. The upstream package ships eight equivalent files and a larger main module, 36,023 bytes against the fork's 29,609.

That matters in one blunt, practical way. In those two clean venvs, import seleniumbase succeeded and printed 4.55.2. import undetected_chromedriver raised ModuleNotFoundError: No module named 'distutils', because the published 3.5.5 artifact still imports distutils.version at the top of patcher.py. The one-line workaround and the full history of that break are documented in nodriver vs undetected-chromedriver; the relevant point here is that SeleniumBase's fork does not have the line, and SeleniumBase installs setuptools anyway, so the framework route works on a modern interpreter with no intervention.

Then there is the project skeleton. Running sbase mkdir uc_demo wrote 20 files across 3 directories: a pytest.ini with 20 markers pre-declared, a setup.cfg, a requirements.txt, four runnable sample tests, a boilerplates/ folder with a base test case and a page-object module, and a samples/ folder under that. None of it is stealth code. All of it is the shape of a test suite.

Two patches on one binary, read side by side

Structurally the two do the same two things. They edit the ChromeDriver executable on disk so the cdc_ symbols it injects into every document stop being findable, and they keep the driver process away from Chrome at moments when a page might look for it. What differs is the edit.

undetected-chromedriver 3.5.5 finds one injected block in the binary and overwrites it with a fixed string, space-padded to the original length so the file size holds. The regex and the replacement literal are three lines of patcher.py, and we print them in the patch-layer comparison rather than repeating them here. The relevant property is that the replacement is a constant: every patched driver on every machine ends up carrying the same bytes.

SeleniumBase 4.55.2 does something different in seleniumbase/undetected/patcher.py. Its live patch_exe() runs three substitutions, and nothing it writes is a constant:

# seleniumbase/undetected/patcher.py, Patcher.patch_exe(), abridged

def gen_js_whitespaces(match):
    return b"\n" * len(match.group())

def gen_call_function_js_cache_name(match):
    rep_len = len(match.group()) - 3
    ran_len = random.randint(6, rep_len)
    return (b"'" + bytes(str().join(random.choices(
        population=string.ascii_letters, k=ran_len)), "ascii")
        + b"';" + (b"\n" * (rep_len - ran_len)))

# 1 and 2: blank the assignment and the guard into newlines of equal length
file_bin = re.sub(
    b"window\\.cdc_[a-zA-Z0-9]{22}_"
    b"(Array|Promise|Symbol|Object|Proxy|JSON|Window) "
    b"= window\\.(Array|Promise|Symbol|Object|Proxy|JSON|Window);",
    gen_js_whitespaces, file_bin)

# 3: replace the call-function cache name with random letters, random length
file_bin = re.sub(
    b"'\\$cdc_[a-zA-Z0-9]{22}_';",
    gen_call_function_js_cache_name, file_bin)

Read what those three passes leave behind. The two assignment forms become runs of newlines exactly as long as the text they replaced. The '$cdc_<22 chars>_'; cache-name string becomes a random run of ASCII letters, between 6 characters and the full slot width, with newlines padding the remainder. So two machines running the same SeleniumBase version produce two different patched drivers.

That is a difference in what the patched file contains. It is not a claim that one gets through more often, and we ran no detection test to support such a claim. A function named patch_exe_beta() sits in the same file and does implement the fixed-literal approach, but patch() and auto() both call patch_exe(), so it is not the live path.

One operational consequence of the fork: SeleniumBase writes the patched binary to uc_driver (uc_driver.exe on Windows) in its drivers directory, beside an unpatched chromedriver. Upstream patches the single binary it downloaded, in place. If you run normal Selenium tests and stealth runs from the same machine, that separation saves you a repatch cycle.

The disconnect primitives are not exclusive to UC Mode. A common misreading is that disconnect-and-reconnect is the framework's invention. It is not. undetected-chromedriver 3.5.5 already has reconnect(timeout=0.1), whose body stops the service, sleeps, starts it, and recreates the session. What UC Mode adds is surface area around that same idea:

Capabilityundetected-chromedriver 3.5.5SeleniumBase 4.55.2 UC Mode
`reconnect(timeout)`YesYes
Standalone `disconnect()` / `connect()`NoYes, separate public calls
Open a URL via the reconnect pathNot provided`uc_open_with_reconnect(url, reconnect_time)`
Open a URL via the disconnect pathNot provided`uc_open_with_disconnect(url, timeout)`
Click with the driver detachedNot provided`uc_click(selector, reconnect_time=...)`
Pause with a human at the keyboardNot provided`reconnect_time="breakpoint"`
OS-level keyboard and mouse helpersNot providedthe `uc_gui_*` family
Context manager that reconnects on exitYes, `__exit__` calls `reconnect`Yes, same inherited behaviour

The "breakpoint" option is the one worth knowing about. Pass it as reconnect_time and the browser sits there with no driver attached until you type c and press enter at a pdb prompt. The documentation is explicit that Selenium commands do not work while that breakpoint is live, which is the whole point: you are driving by hand on a session that has no WebDriver connection.

from seleniumbase import SB

with SB(uc=True, incognito=True, locale="en") as sb:
    d = sb.driver
    d.uc_open_with_reconnect("https://www.sparkproxy.io/pricing", reconnect_time=4)

    # Park the browser with no driver attached, then take it back.
    d.disconnect()
    # ... nothing you call on d works here, by design ...
    d.connect()

    # Schedule the click, drop the driver, reattach after 3 seconds.
    d.uc_click("button[data-consent='accept']", reconnect_time=3)

    # Or hand the session to a human: type "c" + ENTER at the pdb prompt.
    # d.uc_open_with_reconnect(url, reconnect_time="breakpoint")

    sb.assert_element("h1", timeout=20)
    print(sb.get_text("h1"))

The SeleniumBase docs also note that incognito=True is sometimes needed alongside uc=True, and that on headless Linux you want the SB manager rather than Driver, because SB brings a virtual display that the OS-level helpers require.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The HTTP request that happens before your page load

Here is the behaviour that changes how you reason about every run, and it is almost never mentioned. In UC Mode, driver.get() is not Selenium's get().

At launch, SeleniumBase saves the original as driver.default_get and rebinds the name:

driver.default_get = driver.get  # Save copy of original
if uc_activated:
    driver.get = lambda url: uc_special_open_if_cf(
        driver, url, proxy_string, mobile_emulator,
        device_width, device_height, device_pixel_ratio,
    )

uc_special_open_if_cf() fires a plain requests.get() at the URL before Chrome moves anywhere. That helper is small enough to read in full:

def requests_get(url, proxy_string=None):
    protocol = "http"
    proxies = None
    if proxy_string:
        if proxy_string.endswith(":443"):
            protocol = "https"
        elif "socks5" in proxy_string:
            protocol = "socks5"
        proxies = {protocol: proxy_string}
    try:
        response = requests.get(url, proxies=proxies, timeout=1.25)
    except Exception:
        url = url.replace("https://", "http://")
        time.sleep(0.04)
        response = requests.get(url, proxies=proxies, timeout=2.75)
    return response

If that response's status starts with 3, 4 or 5, or if has_captcha(response.text) matches, UC Mode takes the stealthy route: window.open(url, "_blank") from inside the existing tab, close the old one, all wrapped in with driver: so the driver disconnects and reconnects around it. Otherwise it calls default_get and you get an ordinary navigation.

has_captcha() is a 14-way substring test over the raw HTML. A representative slice:

def has_captcha(text):
    return (
        "<title>Just a moment..." in text
        or 'id="challenge-form"' in text
        or 'id="challenge-error-text"' in text
        or "window._cf_chl_opt" in text
        or "/turnstile/" in text
        or 'class="g-recaptcha"' in text
        or "com/recaptcha/api.js" in text
        or 'content="Pixelscan"' in text
        or 'src="chromedriver.js"' in text
    )

Four consequences, in the order they will cost you something.

Every navigation pays a round trip before the browser starts. Up to 1.25 seconds on the first attempt, up to 2.75 more if the first throws and the retry runs over plain http. On a crawl of thousands of URLs that is not noise.

That request leaves from requests, not from Chrome. It does honour your proxy string, so the exit IP matches the one the browser will use, but the TLS handshake and header order are Python's, not a browser's. If the target correlates handshakes per IP, UC Mode has added a non-browser handshake in front of every page view you make. We did not measure whether any particular vendor keys on that; we are telling you the request exists, because you cannot reason about your own fingerprint without knowing it. For the signals detectors are known to combine, see headless browser detection.

The decision is made on the plain-HTTP response. Plenty of sites serve a challenge to requests and clean HTML to Chrome, or the reverse. UC Mode is routing on the former to decide how to do the latter.

You can opt out per call. driver.default_get(url) is the unmodified Selenium navigation, and the docs say plainly that it is faster and has no stealth. Raw undetected-chromedriver has none of this: its get() is return super().get(url) and the cdc-removal hook above it is commented out in the shipped 3.5.5 source.

One job, two codebases

Same task on both sides. Pull an entry price from a pricing page through a country-targeted residential exit, for four countries, with a challenge click if one appears, two retries per country, a screenshot and page source kept on failure, four workers in parallel, and a report at the end.

UC Mode, written as a test because that is what the framework wants:

"""test_prices.py

Run: pytest test_prices.py --uc -n 4 --reruns 2 --reruns-delay 3 \
         --html=report.html --dashboard --archive-logs
"""
import json
import os

from parameterized import parameterized
from seleniumbase import BaseCase

BaseCase.main(__name__, __file__)  # also lets "python test_prices.py" run it

GATEWAY = "gateway.sparkproxy.io:11000"
USER = os.environ["SPARKPROXY_USER"]
SECRET = os.environ["SPARKPROXY_PASS"]


class PriceTests(BaseCase):
    @parameterized.expand([["de"], ["us"], ["gb"], ["jp"]])
    def test_pricing(self, country):
        proxy = "%s-country-%s:%s@%s" % (USER, country, SECRET, GATEWAY)
        self.get_new_driver(undetectable=True, proxy=proxy, multi_proxy=True)
        self.driver.uc_open_with_reconnect(
            "https://www.sparkproxy.io/pricing", reconnect_time=4
        )
        if self.is_element_visible("iframe[title*='challenge']"):
            self.driver.uc_gui_click_captcha()
        self.assert_element("h1", timeout=20)
        price = self.get_text("[data-plan='entry'] .price")
        with open("out-%s.json" % country, "w", encoding="utf-8") as fh:
            json.dump({"country": country, "price": price}, fh)

Note what is absent. No thread pool: -n 4 hands that to pytest-xdist. No retry loop: --reruns 2 --reruns-delay 3 is pytest-rerunfailures. No screenshot-on-failure: the framework writes one. No report writer: --html and --dashboard produce those. No proxy auth extension: proxy="USER:PASS@SERVER:PORT" is a documented string format and SeleniumBase builds the extension itself.

The same job on raw undetected-chromedriver, with every one of those pieces written out:

"""prices_uc.py  ->  python prices_uc.py"""
import json
import os
import shutil
import tempfile
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed

import undetected_chromedriver as uc
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

HOST, PORT = "gateway.sparkproxy.io", 11000
USER = os.environ["SPARKPROXY_USER"]
SECRET = os.environ["SPARKPROXY_PASS"]
COUNTRIES = ["de", "us", "gb", "jp"]

MANIFEST = json.dumps({
    "manifest_version": 3,
    "name": "px",
    "version": "1.0",
    "permissions": ["proxy", "webRequest", "webRequestAuthProvider"],
    "host_permissions": ["<all_urls>"],
    "background": {"service_worker": "background.js"},
})

BACKGROUND = """
chrome.proxy.settings.set({value: {mode: "fixed_servers", rules: {
  singleProxy: {scheme: "http", host: "%s", port: %d}}}, scope: "regular"});
chrome.webRequest.onAuthRequired.addListener(
  () => ({authCredentials: {username: "%s", password: "%s"}}),
  {urls: ["<all_urls>"]}, ["asyncBlocking"]);
"""


def auth_extension_dir(user, secret):
    """Chrome takes a proxy address on the command line but not a password."""
    folder = tempfile.mkdtemp(prefix="px_")
    with open(os.path.join(folder, "manifest.json"), "w") as fh:
        fh.write(MANIFEST)
    with open(os.path.join(folder, "background.js"), "w") as fh:
        fh.write(BACKGROUND % (HOST, PORT, user, secret))
    return folder


def scrape_once(country):
    user = "%s-country-%s" % (USER, country)
    ext = auth_extension_dir(user, SECRET)
    options = uc.ChromeOptions()
    options.add_argument("--load-extension=%s" % ext)
    options.add_argument("--proxy-server=http://%s:%d" % (HOST, PORT))
    driver = uc.Chrome(options=options, use_subprocess=True)
    try:
        driver.get("https://www.sparkproxy.io/pricing")
        WebDriverWait(driver, 20).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, "h1"))
        )
        return driver.find_element(
            By.CSS_SELECTOR, "[data-plan='entry'] .price"
        ).text
    except (TimeoutException, Exception):
        driver.save_screenshot("fail-%s.png" % country)
        with open("fail-%s.html" % country, "w", encoding="utf-8") as fh:
            fh.write(driver.page_source)
        raise
    finally:
        driver.quit()
        shutil.rmtree(ext, ignore_errors=True)


def scrape(country, attempts=3):
    for n in range(1, attempts + 1):
        try:
            return {"country": country, "price": scrape_once(country)}
        except Exception:
            if n == attempts:
                return {"country": country, "error": traceback.format_exc()}


if __name__ == "__main__":
    rows = []
    with ThreadPoolExecutor(max_workers=4) as pool:
        futures = [pool.submit(scrape, c) for c in COUNTRIES]
        for f in as_completed(futures):
            rows.append(f.result())
    with open("out.json", "w", encoding="utf-8") as fh:
        json.dump(rows, fh, indent=2)
    bad = [r for r in rows if "error" in r]
    print("%d ok, %d failed" % (len(rows) - len(bad), len(bad)))

Counting non-blank, non-comment lines and excluding the run-command docstring: 21 lines against 78. Both files compile under python -I -m py_compile.

And still the second one has no challenge handling. There is no UC equivalent of uc_gui_click_captcha(), so that branch would be yours to build on top of a CAPTCHA service or an OS-level input library. The honest ratio is worse than 21 to 78.

Read the second block as a cost estimate rather than a verdict. Every line in it is ordinary Python you probably already have somewhere: a thread pool, a retry decorator, a failure-artifact helper. If your codebase has those, UC Mode's version of them is duplicate infrastructure, and the 39 extra packages buy you nothing. If it does not, you are about to write them.

What the runner hands you for free

Spelling out the free column, since the argument lives there.

Failure artifacts. On a failed test, SeleniumBase writes a per-test folder under latest_logs/ containing screenshot.png, basic_test_info.txt and page_source.html. The folder is cleared at the start of each run unless you pass --archive-logs, which copies it into archived_logs instead. Downloads behave the same way: downloaded_files is wiped per run, --archive-downloads preserves it in archived_files.

A dashboard. --dashboard builds a dashboard.html that carries a 12-second meta refresh, plus dashboard.json and dash_pie.json for the chart data. Pointed at a browser during a long run it updates itself.

Parallelism, reruns and reporting as flags. -n 4 is pytest-xdist and the UC Mode docs say so directly. --reruns N --reruns-delay S is pytest-rerunfailures. --html=report.html is pytest-html. --rs, whose help text reads "reuse the selenium browser window session for all tests", avoids relaunching per test, which on stealth runs is a real saving because each launch is a fresh profile and a fresh patched-driver check.

A command-line surface. pytest --help inside that venv lists 123 option entries under the heading "SeleniumBase command-line options for pytest", including --uc (aliased --undetected and --undetectable), --uc-subprocess, --uc-cdp-events, --xvfb, --slow-mode, --demo-mode, --archive-logs and --driver-version. Separately, sbase exposes 27 subcommands: a project generator, a recorder that writes test code from your clicks, a Selenium Grid hub and node launcher, a test-plan printer, an encryptor, a presentation builder and a translator.

Now the bill. Adopting the runner means adopting pytest's collection rules. The generated pytest.ini sets python_files = test_.py _test.py _tests.py _suite.py and python_classes = Test Test Test Tests Suite, so your scraper has to be named like a test and live where pytest looks. If your scraper is a Celery task, a cron entry or a queue consumer, that shape fights you, and the flags above stop being free because nothing is invoking pytest.

SeleniumBase has an answer: the SB() context manager and the Driver() factory both work in a plain script with no pytest involved.

from seleniumbase import Driver

driver = Driver(uc=True)
try:
    driver.get("https://www.sparkproxy.io/pricing")
    driver.assert_element("h1")
    print(driver.get_text("h1"))
finally:
    driver.quit()

That is four meaningful lines and it is genuinely pleasant. But notice what you are paying for it. Outside the runner you lose the failure artifacts, the dashboard, the reruns, the xdist threading and the HTML report, which is the entire justification for the other 39 packages. A script-only user of UC Mode is carrying a test framework to get a nicer assert_element(). That is a defensible trade for the authenticated-proxy handling and the CAPTCHA helpers alone, but it should be a decision, not an accident. Our guide to scheduling and automating web scrapers covers what the orchestration layer looks like when pytest is not it.

Authenticated proxies: built in on one side, absent on the other

This is the sharpest functional gap between the two, and it is bigger than the usual write-ups suggest.

Grep the entire installed undetected_chromedriver 3.5.5 package for the string proxy and you get exactly one hit: value: new Proxy(navigator, { inside an injected script, which has nothing to do with network proxies. No flag helper, no credential plumbing, no extension generator, no documentation. Chrome will take a proxy address on the command line, but it has never accepted a password there, so on the raw library an authenticated pool means either allowlisting the egress IP of every box that runs the scraper or writing the extension in the code block above yourself.

SeleniumBase treats it as a parameter. The SB manager's docstring states the accepted shapes verbatim: proxy (str): Use proxy. Format: "SERVER:PORT" or "USER:PASS@SERVER:PORT". Internally proxy_helper.create_proxy_ext() writes a background.js and a manifest declaring "service_worker": "background.js", then adds --proxy-server= and, if you supplied one, --proxy-bypass-list=. There is a UC-specific branch in that path worth knowing about:

if proxy_auth:
    zip_it = True
    if is_using_uc(undetectable, browser_name):
        zip_it = False  # undetected-chromedriver needs a folder ext

For UC Mode the extension is unpacked to a directory instead of loaded as a zip, because the stealth launch path cannot take a packed one. That is exactly the kind of detail you would spend an afternoon discovering on your own. There is also multi_proxy=True, documented as allowing multiple authenticated proxies when multithreaded, which is what makes the four-country parameterized test above work under -n 4.

On the SparkProxy side either auth style works. Plans accept IP whitelisting or user:pass, over HTTP, HTTPS or SOCKS5, on a 1 Gbps port. Because bandwidth is unlimited on every plan, the billing unit is the plan and its concurrency rather than the traffic a browser run consumes, which removes the usual reason to avoid rendering through residential exits. Entry pricing starts from $75/month (USD), term discounts run 5% at three months, 10% at six and 15% at twelve, and there is a 24-hour trial with no card. If you are wiring any of this into Selenium for the first time, how to integrate proxies with Selenium walks the setup, and best proxies for Selenium covers which pool type suits which target.

One thing neither package does is give you an IP. Patching automation tells and sourcing clean exits are separate problems, and the second is usually the one actually failing. Residential vs datacenter proxies is the decision that matters more than either library.

The pip install that happens at runtime

Here is the one that belongs in your CI notes.

UC Mode's OS-level helpers, the uc_gui_click_captcha() family and everything else prefixed uc_gui_, drive the real keyboard and mouse through PyAutoGUI. On Windows and macOS, PyAutoGUI is not in the base install; it is an extra. We confirmed that in the fresh venv: find_spec("pyautogui") returned None after a plain pip install seleniumbase.

SeleniumBase handles the gap by installing it for you, mid-run. From browser_launcher.py:

def __install_pyautogui_if_missing():
    try:
        import pyautogui
        with suppress(Exception):
            use_pyautogui_ver = constants.PyAutoGUI.VER
            u_pv = shared_utils.make_version_tuple(use_pyautogui_ver)
            pv = shared_utils.make_version_tuple(pyautogui.__version__)
            if pv < u_pv:
                del pyautogui  # To get newer ver
                shared_utils.pip_install("pyautogui", version="Latest")
                import pyautogui
    except Exception:
        print("\nPyAutoGUI required! Installing now...")
        shared_utils.pip_install("pyautogui", version="Latest")

shared_utils.pip_install() is subprocess.check_call([sys.executable, "-m", "pip", "install", package]) guarded by a fasteners.InterProcessLock, so parallel workers do not race each other into the same resolve. The fallback version constant is PyAutoGUI.VER = "0.9.54", and note the upper branch: an existing PyAutoGUI older than that constant gets upgraded to latest, in your environment, without asking.

Reasonable behaviour for a developer on a laptop. Not reasonable in several places you might be running:

  • a container with a read-only filesystem, where the install fails and takes the run with it
  • a CI job with no outbound access to PyPI
  • any build that claims reproducibility, since the dependency set now differs between the image you tested and the image that solved a CAPTCHA
  • an air-gapped environment, obviously

There is a second guard in the same file: verify_pyautogui_has_a_headed_browser() raises PyAutoGUI can't be used in headless mode! if the browser is hidden, so these methods need a real display or a virtual one. On Linux, xvfb=True or --xvfb gives you that, and the docs note the virtual display is on by default in UC Mode when you do not touch the headed and headless settings.

Pin it at build time and fail loudly instead:

import importlib.util
import sys

if importlib.util.find_spec("pyautogui") is None:
    sys.exit(
        "PyAutoGUI missing. SeleniumBase would pip-install it mid-run; "
        "install it at image-build time instead:\n"
        "  pip install 'seleniumbase[pyautogui]'   # non-Linux\n"
        "  pip install pyautogui python-xlib       # Linux, plus Xvfb"
    )

Raw undetected-chromedriver installs nothing at runtime. It downloads and patches a ChromeDriver binary on first use, which needs write access to the driver path, but it never touches your Python environment.

Pin weight, and when it bites

The dependency counts in the first table understate the difference, because the two projects express their requirements differently.

undetected-chromedriver's setup.py declares four, all as floors: selenium>=4.18.1, requests>=2.31.0, websockets>=12.0, packaging>=23.0. Nothing is capped. It declares no requires_python, and its trove classifiers run from 3.7 to 3.13.

SeleniumBase pins hard. Reading the 4.55.2 metadata on PyPI, exact-equality pins include selenium==4.50.0, pytest==9.1.1 on Python 3.11 and newer with pytest==8.4.2 below that, pytest-html==4.0.2, pytest-xdist==3.8.0, pytest-rerunfailures==16.7, behave==1.2.6, pluggy==1.6.0, execnet==2.1.2, sortedcontainers==2.4.0, sniffio==1.3.1 and h11==0.16.0, with compatible-release pins such as requests~=2.34.2 and beautifulsoup4~=4.15.0.

That is a deliberate choice by a project that ships a test runner, and it has a predictable consequence. Dropping SeleniumBase into an existing repo that already runs pytest at a different version, or that pins requests, starts a resolver fight you will lose. The practical answer is boring and works: give it its own virtualenv or its own container image, and talk to it over a queue or a file rather than importing it next to your application code. The selenium==4.50.0 pin in particular means you cannot upgrade Selenium independently of SeleniumBase.

The flip side is that the pinned set is a tested set. With undetected-chromedriver you own the compatibility question yourself, and since the published artifact predates several Selenium releases, "any selenium above 4.18.1" is a floor the project has not actually exercised against current versions.

Release cadence, with the dates we read today

Compressed, because the maintenance story for undetected-chromedriver is covered at length in the nodriver migration guide. The dates, read from PyPI and the GitHub API on 9 October 2026:

Signalundetected-chromedriverSeleniumBase
Latest PyPI release and date3.5.5, 2024-02-174.55.2, 2026-10-08
Previous releasesnone taggedv4.55.1 2026-10-06, v4.55.0 2026-10-01, v4.54.13 2026-09-30
Last push to default branch2025-07-052026-10-08
Tagged GitHub releasesnoneyes
Open issues1,14110
Stars / forks12,863 / 1,35213,058 / 1,593
Repo created2019-12-222014-03-04
Archivednono

The star counts sit within 2% of each other, which is why star count tells you nothing here. The two latest release dates are thirty-two months apart. The newest work on undetected-chromedriver's master is still a merged community pull request from 5 July 2025 whose commit message reads "Refactor: Update for Python 3.13+ compatibility", and it has never been cut into a PyPI release; the commit before it is "3.5.5" from 17 February 2024. The 1,141 open issues are frozen rather than growing: the GitHub API returns has_issues: false for that repo today, so nobody can file the next one, and has_discussions: true is where the traffic went.

One more date that belongs in this comparison and rarely appears. SeleniumBase's own UC Mode documentation page now opens with a pointer away from itself: "For the successor to plain UC Mode, see CDP Mode". Inside SeleniumBase, UC Mode is the older of two stealth paths. Several methods the current UC Mode page demonstrates reflect that; solve_captcha is attached to the driver inside the CDP Mode activation path in browser_launcher.py, not in the plain UC Mode launch, while the documented plain-UC CAPTCHA entry points remain the uc_gui_* methods. If you are choosing UC Mode today, read that pointer before you build on it.

Measured side by side

undetected-chromedriver 3.5.5SeleniumBase 4.55.2 UC Mode
What it isa librarya feature inside a test framework
Install footprint21 dists, 55.9 MB60 dists, 95.2 MB
Declared requires4, all floors57, many exact pins
Stealth sourceown `patcher.py`vendored fork at `seleniumbase/undetected/`
Driver patch replacementfixed literalrandomised per patch
Patched binary namethe downloaded `chromedriver`separate `uc_driver`
`driver.get()`plain Selenium `get()``requests` pre-flight, then branch
Authenticated proxiesnothing in the package`proxy="USER:PASS@SERVER:PORT"`
CAPTCHA helpersnone`uc_gui_*`, needs PyAutoGUI and a display
Test runnernonepytest, behave, pynose
Parallelismyou write it`-n 4` via pytest-xdist
Retriesyou write it`--reruns N`
Failure artifactsyou write it`latest_logs/` automatically
CLInone`sbase` with 27 subcommands, 123 pytest flags
Runtime self-installneverPyAutoGUI on first `uc_gui_*` call
LicenseGPL-3.0MIT
Imports on a clean 3.13.5 venvno, needs the `setuptools` shimyes

Which one to pick, by what you already own

You already run pytest. Take UC Mode. The 39 extra packages are versions of things you have, and the flags collapse a lot of glue code. Budget a separate venv for the pin set.

Your scraper is a worker in a queue or a cron job. The runner does nothing for you, so you are weighing the SB() ergonomics plus authenticated proxies plus the CAPTCHA helpers against 95 MB and a pinned Selenium. Both answers are reasonable; just make it on purpose.

You need authenticated proxies and do not want to write a Chrome extension. UC Mode, clearly. This is the single biggest functional gap on the page.

You need the smallest possible container. Raw undetected-chromedriver is 40 MB and 39 packages lighter, and installs nothing at runtime. Add the setuptools shim or install from master rather than PyPI.

GPL-3.0 is a problem for your distribution. SeleniumBase is MIT. Upstream undetected-chromedriver is GPL-3.0. Ask your own counsel, not a scraping blog.

You need something that installs and imports cleanly on 3.13 or 3.14 today. SeleniumBase, with no workaround. It also declares requires_python >=3.10, so a 3.9 environment rules it out.

You want plain UC Mode specifically. Read SeleniumBase's own pointer to CDP Mode first, because within that project UC Mode is now the older path.

If the real question underneath this is Selenium versus something else entirely, Playwright vs Selenium for web scraping is the comparison to read instead.

When neither one is the right tool

A patched Chrome earns its cost when the data only exists after JavaScript runs and the target actively fingerprints the browser. It is the wrong tool when you are paying browser money for HTML a plain fetch would have returned, and it leaves the harder half of the problem, keeping a pool of clean exits healthy, entirely to you.

SparkProxy's Scraping API does the rendering and the exit in one request. The envelope matters here, because it is the part people get wrong:

import base64
import json

import requests

params = {
    "url": "https://www.sparkproxy.io/pricing",
    "render_js": "true",          # default is already true, 5 credits
    "country_code": "DE",         # +5 credits
    "proxy_type": "premium",      # file-based premium pool
    "format": "md",               # CONTENT conversion: html|md|mdx|json
    "json_response": "true",      # the only source of the envelope
    "js_scenario": json.dumps({   # +5 credits, max 50 steps
        "instructions": [
            {"wait_for": "h1"},
            {"click": "button[data-consent='accept']"},
            {"wait": 1000},
        ]
    }),
}

r = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params=params,
    timeout=120,
)
r.raise_for_status()

env = r.json()
print(env["status_code"], env["duration_ms"], env["credits_used"])

# The envelope body is base64. Decode it before you match anything.
markdown = base64.b64decode(env["body"]).decode("utf-8")
print("blocked" if "Just a moment" in markdown else "ok", len(markdown))

Three rules that catch people. format only chooses how the content is converted, so format=json gives you JSON content, not the envelope; json_response=true is the only thing that produces job_id, status_code, duration_ms, credits_used and body. That body is base64, so any marker matching on it before decoding will silently never match. And extract_rules replaces the response with an extracted object, so it does not stack with json_response. The call above costs 15 credits: 5 for render_js, 5 for country_code, 5 for js_scenario. Drop render_js to false and a plain fetch is 1 credit, which is also what a whole batch costs in batch mode. The first 1,000 credits are free.

Self-hosting either Python library with your own pool is cheaper per request at volume on easy targets. The trade is argued properly in web scraping API vs self-managed proxies.

Frequently asked questions

FAQ

No. SeleniumBase has no dependency on the undetected-chromedriver package; pip show seleniumbase lists 57 requirements and that is not one of them. It carries a seven-module fork at seleniumbase/undetected/ whose live patch_exe() writes randomised bytes rather than the upstream project's fixed replacement literal, and it adds disconnect(), connect(), uc_open_with_reconnect(), uc_click() and the uc_gui_* helpers on top.

Measured in clean CPython 3.13.5 virtualenvs on 9 October 2026: pip install seleniumbase resolved 60 distributions and 95.2 MB of site-packages, against 21 distributions and 55.9 MB for pip install undetected-chromedriver. The gap is mostly pytest, pytest-xdist, pytest-html, pytest-rerunfailures, behave, pynose, rich and Jinja2.

Yes. The SB() context manager and the Driver() factory both run in a plain script. What you give up is everything the runner provides: the latest_logs/ failure artifacts, --dashboard, --reruns, -n threading and the HTML report. Outside pytest you are keeping the framework mainly for its authenticated-proxy handling and CAPTCHA helpers.

Not in any form. Searching the whole installed 3.5.5 package for the string proxy returns one unrelated JavaScript line. Chrome accepts a proxy address on the command line but never a password, so you either allowlist your egress IPs or write a Chrome extension that answers the auth challenge. SeleniumBase accepts proxy="USER:PASS@SERVER:PORT" and generates that extension itself, unpacked as a folder because the UC launch path cannot load a zip.

That question does not have an honest answer on this page, because we ran no detection test. Both patch the same ChromeDriver binary and both detach the driver from Chrome during sensitive actions; the patch bytes differ, with one fixed and one randomised. For a comparison built around what each stealth layer can and cannot hide, read undetected-chromedriver vs Patchright vs Camoufox.

Because UC Mode's uc_gui_* methods need PyAutoGUI, which is an optional extra on Windows and macOS, and __install_pyautogui_if_missing() runs subprocess.check_call([sys.executable, "-m", "pip", "install", "pyautogui"]) when the import fails. It will also upgrade a PyAutoGUI older than its pinned 0.9.54 constant. Install it at image-build time, or check for it at startup and exit with a clear message.

Special Discount ยท 20% off

Get 20% off your first month

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

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter proxies, residential proxies and Scraping API. We run our own headless rendering infrastructure, which is why this comparison is built from the two projects' source rather than from other articles. Every version, date and count on this page came from PyPI JSON, the GitHub API and the installed packages themselves on 9 October 2026. The footprint numbers, the package counts, the 123 pytest option entries, the 20-file sbase mkdir scaffold and the absent PyAutoGUI were measured in two clean CPython 3.13.5 virtualenvs on that date, and every Python block here compiles under python -I -m py_compile. Where we did not measure something, detection outcomes above all, we say so instead of guessing. Corrections welcome at support@sparkproxy.io.

Keep reading

Related articles

Insomniac Browser Alternatives, Scored by Layer

Insomniac Browser alternatives scored by which identity layer each one really covers, with prices verified in October 2026 and a measured three-profile test.

SparkProxyยทComparisons