PRACTICAL GUIDE / Selenium SessionNotCreated browser version mismatch

Chrome updated overnight and every Selenium session now dies at startup

SessionNotCreatedException before your first navigation is a driver resolution problem, not a flaky test. Read the message, prove the cause, pin it in CI.

By The Testing AcademyUpdated August 4, 202623 min read
All field guides
In this guide13 sections
  1. The refusal comes from the driver, not from Selenium
  2. Where the driver version actually comes from
  3. Worked example one: the cache hands back a stale driver, quietly
  4. Worked example two: your own pin is the thing that broke it
  5. Worked example three: on a Grid, Selenium Manager is off by default
  6. How to tell it is this and not a look-alike
  7. The tolerance is real and you should still ignore it
  8. The fix, and what it costs
  9. The second failure mode: the mismatch inverts
  10. Rolling this out without a big-bang week
  11. When not to do this
  12. FAQ
  13. Does the ChromeDriver major version have to match Chrome exactly?
  14. Why did the error appear even though Selenium Manager is supposed to handle this?
  15. What is the difference between SessionNotCreatedException and NoSuchDriverException?
  16. Should I pin the driver version or the browser version?
  17. Is retrying the session a reasonable mitigation?
  18. How do I stop Chrome from updating on a CI runner?
  19. Practise the diagnosis

What you will learn

  • The refusal comes from the driver, not from Selenium
  • Where the driver version actually comes from
  • Worked example one: the cache hands back a stale driver, quietly
  • Worked example two: your own pin is the thing that broke it

At 6am the Chrome pipeline was green. At 9am every job in it dies in under two seconds, before a single driver.get() runs, and the report is a wall of identical stack traces. Nobody merged anything to the test framework. What changed is that the CI image pulled a new Chrome overnight, and the driver sitting next to it did not move.

Here is the message, captured from a real reproduction on Selenium 4.39.0 with Python:

Example
selenium.common.exceptions.SessionNotCreatedException: Message: session not created:
This version of ChromeDriver only supports Chrome version 131
Current browser version is 151.0.7922.76 with binary path /Applications/Google Chrome.app/Contents/MacOS/Google Chrome;
For documentation on this error, please visit:
https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#sessionnotcreatedexception

Two version numbers in one message. That shape is the whole diagnosis, and almost everything else in this article exists to help you find out which of the two moved.

The refusal comes from the driver, not from Selenium

The Selenium documentation lists three likely causes for SessionNotCreatedException: an incompatible browser and WebDriver pair, macOS privacy settings blocking the driver, and a driver binary that is missing, inaccessible, or not executable. Version mismatch is the first and by far the most common, and it is worth being precise about who is actually saying no.

Your test client speaks the W3C WebDriver protocol. The first thing it does is POST a New Session request to the driver process. The driver launches the browser, handshakes with it, compares the browser build it just got against the build it was compiled to drive, and if it does not like the answer it returns the session not created error. Selenium's job in that exchange is to translate the returned error into SessionNotCreatedException and hand it to you. Selenium never inspected a version and never made a judgement.

That matters for three practical reasons.

Nothing about your test code is implicated. No locator, no wait, no fixture, no page object ran. The failure is at the boundary where the process was created, so any hypothesis that starts "maybe the login page changed" is already wrong.

The error text is written by the driver vendor, not by Selenium, so the wording differs per browser. The This version of ChromeDriver only supports Chrome version N phrasing is ChromeDriver's. Microsoft Edge WebDriver produces its own equivalent. Geckodriver's compatibility story with Firefox is different again and will not give you the same sentence. Do not grep CI logs for ChromeDriver's exact string and call that a cross-browser detector.

And it is deterministic. The same two binaries refuse each other every single time. There is no timing component, no race, and no amount of waiting that helps.

Where the driver version actually comes from

Most teams cannot answer "which chromedriver ran?" quickly, which is why this failure feels mysterious. Since Selenium 4.6 the bindings ship Selenium Manager, and the resolution order it introduces is where the surprises live.

The documented order, from the Selenium Manager page, is that Selenium Manager is a fallback: it activates when drivers are not available on the system PATH or through manual configuration. Its own configuration then has three layers, in priority order: CLI arguments, then the se-config.toml configuration file, then environment variables.

The Python bindings add one more step in front of all of that. Reading selenium/webdriver/common/driver_finder.py in 4.39.0, if you pass a path to the Service class, Selenium Manager is never invoked at all. The binding logs it:

Example
Skipping Selenium Manager; path to chrome driver specified in Service class: /usr/local/bin/chromedriver

So the practical precedence, from strongest to weakest, is: an explicit Service(executable_path=...), then a driver already on PATH, then whatever Selenium Manager resolves. Three different mechanisms, and a repository that uses all three in different files will produce three different drivers on the same machine.

Selenium Manager is a standalone Rust binary that ships inside the language binding, so you can run it yourself and watch it decide. On a Python install it lives under selenium/webdriver/common/<platform>/selenium-manager.

Shell
# Locate the Selenium Manager binary that YOUR interpreter will use
SM=$(python3 -c "import selenium, pathlib, sys; \
  p = pathlib.Path(selenium.__file__).parent / 'webdriver' / 'common'; \
  d = {'darwin': 'macos', 'linux': 'linux', 'win32': 'windows'}[sys.platform]; \
  print(next((p / d).glob('selenium-manager*')))")

echo "manager: $SM"
"$SM" --version

# Ask it, out loud, what it would hand to the bindings right now.
"$SM" --browser chrome --debug --avoid-stats

The --debug flag is documented on the Selenium Manager page alongside --trace, and it turns the resolution into a readable trace. This is the single highest-value command in this whole article, because it separates "the driver is wrong" from "the driver is not what I think it is".

Worked example one: the cache hands back a stale driver, quietly

Here is the trace from a machine where this actually happened. I ran Selenium Manager with --offline, which the tool documents as disabling network requests and downloads, to simulate an air-gapped or network-restricted runner:

Example
[DEBUG] Using Selenium Manager in offline mode
[DEBUG] chromedriver not found in PATH
[DEBUG] chrome detected at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[DEBUG] Running command: /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
[DEBUG] Output: "Google Chrome 151.0.7922.76 "
[DEBUG] Detected browser: chrome 151.0.7922.76
[DEBUG] There was an error managing chromedriver (Unable to discover proper chromedriver version
        in offline mode); using driver found in the cache
[INFO ] Driver path: /Users/me/.cache/selenium/chromedriver/mac-arm64/150.0.7871.115/chromedriver
[INFO ] Browser path: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

Read the last three lines together. Selenium Manager correctly detected Chrome 151, could not reach the network to find the matching driver, fell back to a cached ChromeDriver 150, and reported success with exit code 0. The bindings got a driver path. Nothing raised. The mismatch does not become an error until the driver is launched and refuses the browser, several seconds and one stack frame later.

The same shape appears without --offline whenever the cached metadata is still fresh. Selenium Manager documents a TTL for discovered versions, --ttl, defaulting to 3600 seconds, during which it reuses what it already discovered instead of asking the network again. A browser that self-updates inside that hour keeps getting the previous hour's answer. On a long-lived build agent that reuses ~/.cache/selenium across jobs, this is not an edge case, it is Tuesday.

Two commands make that state visible and disposable:

Shell
# What has this agent actually cached? (default cache path: ~/.cache/selenium)
find ~/.cache/selenium -maxdepth 3 -name chromedriver -type f | sort

# Force a fresh discovery instead of reusing cached metadata.
"$SM" --clear-metadata
"$SM" --browser chrome --debug --avoid-stats

# Nuclear option, when the cache itself is suspect.
"$SM" --clear-cache

--clear-cache and --clear-metadata are both listed in selenium-manager --help; on the build I tested (0.4.39) the help text names the metadata file as ~/.cache/selenium/selenium-manager.json. Check your own --help output before scripting against a filename, because cache layout is an implementation detail and has changed across releases.

Worked example two: your own pin is the thing that broke it

The second case is more embarrassing and much more common on teams that have already been burned once. Somebody hit the mismatch six months ago, pinned a driver version to make it stop, and moved on. The pin then outlived the browser it matched.

Selenium Manager reads se-config.toml from the cache directory, and driver-version is one of its documented keys. Here is what a pinned config does when the browser has moved past it:

Example
[DEBUG] chrome detected at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[DEBUG] Output: "Google Chrome 151.0.7922.76 "
[DEBUG] Detected browser: chrome 151.0.7922.76
[DEBUG] chromedriver 150.0.7871.115 already in the cache
[INFO ] Driver path: /Users/me/.cache/selenium/chromedriver/mac-arm64/150.0.7871.115/chromedriver

Selenium Manager detected Chrome 151, saw that you had asked for driver 150, found 150 in the cache, and gave you 150. It did exactly what it was told. It did not warn that the two lines disagree, because reconciling them is not its job when you have overridden the resolution.

The lesson generalises past this one tool. A pin is a promise that you will maintain it. An unmaintained driver-version, an unmaintained chromedriver baked into a Docker layer, and an unmaintained Service(executable_path=...) all decay the same way, and all of them decay silently until session creation.

While you are in there, be aware that Selenium Manager's config keys can be set three ways for the same setting, which is a real source of confusion during an incident:

TOML
# ~/.cache/selenium/se-config.toml
# Equivalent CLI flags: --browser, --browser-version, --cache-path
# Equivalent env vars:  SE_BROWSER, SE_BROWSER_VERSION, SE_CACHE_PATH
# Priority order (documented): CLI arguments > this file > environment variables

browser = "chrome"
browser-version = "151"
cache-path = "/opt/selenium/cache"

Note what this example does not contain. There is no driver-version line. Pin the browser and let the driver be derived from it. If you pin both, you have created two sources of truth that can disagree, and you will find out which one won at 9am on a Tuesday.

Worked example three: on a Grid, Selenium Manager is off by default

Everything above describes a driver resolved on the machine running your test code. On Selenium Grid the resolution happens on the Node, and the defaults are different in a way that catches people out.

From the Grid CLI options documentation, the Node flag --selenium-manager is a boolean that defaults to false, described as: use Selenium Manager when drivers are not available on the current system. Meanwhile --detect-drivers defaults to true, meaning the Node autodetects which drivers are available on the system and adds them.

Put those together and the default Node behaviour is: find drivers already installed on this box, register slots for them, and do not download anything. A Grid Node is therefore a machine where "just update Chrome" quietly breaks the pairing, because nothing on that Node is watching for the mismatch. The latest tag on a selenium/node-chrome image is the containerised version of the same trap: the image pairs a browser and driver correctly at build time, and latest re-pairs them on a schedule you do not control.

The client-side symptom is identical to the local one, which is the problem. You get SessionNotCreatedException with two version numbers in it, and the numbers describe a machine you are not sitting at. Resist the urge to debug your laptop.

How to tell it is this and not a look-alike

Four failures in this neighbourhood produce panic and get misfiled as version mismatch. Each has a clean discriminator.

The message names two versions. This is the positive test, and it is nearly sufficient on its own. This version of ChromeDriver only supports Chrome version 131 plus Current browser version is 151.0.7922.76 is a mismatch and nothing else. If your exception does not contain two version numbers, stop reading this section and go somewhere else.

NoSuchDriverException is a different failure. This is the one people most often confuse with a mismatch, because both happen at startup. In the Python bindings, DriverFinder wraps any resolution failure in NoSuchDriverException with the message Unable to obtain driver for chrome and the real cause chained underneath. That exception means no driver binary was ever obtained, so nothing launched and nothing compared versions. Causes are typically network or proxy failures reaching the download host, a wrong --browser-path, or an explicitly configured driver path that is not a valid file. Read the chained cause. It is far more informative than the wrapper.

Permissions and platform blocks say nothing about versions. The Selenium docs call out macOS privacy settings blocking the driver and a driver file that lacks the execute bit. These surface as launch failures or WebDriverException with an operating system flavoured message, not as a version comparison. chmod +x /path/to/driver on Linux and macOS is the documented fix for the second one.

Container resource failures look like startup failures but mention the browser process. Chrome dying inside a small container typically shows up as an error about the browser crashing or the DevTools port, and the message will not contain a pair of version numbers. This is a container sizing problem, not a driver problem.

Once you are past the triage, the following script settles the question with facts rather than inference. It is deliberately boring: it prints what actually got used.

Python
"""Prove which browser and which driver a session actually negotiated.

Run this before you change anything. The three printed values are the entire
argument: browserVersion is what launched, chromedriverVersion is what drove it,
and the driver path tells you which resolution mechanism won.
"""

import json

from selenium import webdriver
from selenium.common.exceptions import NoSuchDriverException, SessionNotCreatedException

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")

try:
    driver = webdriver.Chrome(options=options)
except SessionNotCreatedException as exc:
    # A driver was found and it refused this browser. Version mismatch.
    print("MISMATCH at session creation:")
    print(exc.msg)
    raise
except NoSuchDriverException as exc:
    # No driver was ever obtained. This is resolution, not compatibility.
    print("RESOLUTION FAILED, no driver binary was obtained.")
    print("chained cause:", repr(exc.__cause__))
    raise

try:
    caps = driver.capabilities
    print(
        json.dumps(
            {
                "browserName": caps.get("browserName"),
                "browserVersion": caps.get("browserVersion"),
                "chromedriverVersion": caps.get("chrome", {}).get("chromedriverVersion"),
                "driverPath": driver.service.path,
            },
            indent=2,
        )
    )
finally:
    driver.quit()

Against Chrome 151 on the machine I tested, that prints a browserVersion of 151.0.7922.76 and a chromedriverVersion beginning 150.0.7871.115. Two adjacent majors, one working session. Which brings us to an uncomfortable finding.

The tolerance is real and you should still ignore it

Running the reproduction across three cached drivers against Chrome 151.0.7922.76 gave three different outcomes:

ChromeDriver buildResult against Chrome 151.0.7922.76
150.0.7871.115Session created successfully
131.0.6778.204SessionNotCreatedException, only supports Chrome version 131
125.0.6422.141SessionNotCreatedException, only supports Chrome version 125

These are observations from one machine on one afternoon, not a compatibility matrix. The honest reading is that ChromeDriver's acceptance window is wider than "exact major match" at least some of the time, that the width is not documented, and that it can change in any release without notice.

So do not design around it. If your pinning strategy depends on a driver being allowed to run one major behind, you have made an undocumented implementation detail load-bearing, and the day it tightens you get a full-suite outage with no code change to blame. Design for exact matching and let the tolerance be a thing that occasionally saves you rather than a thing you rely on.

The fix, and what it costs

The durable fix has one sentence: stop letting the browser move on its own, and derive the driver from the browser you chose.

Chrome for Testing exists precisely for this. It publishes matched browser and driver builds under a single version number, and unlike the Chrome you install from the website, it does not auto-update. Here is the CI wiring, using the browserVersion capability so Selenium Manager resolves both halves from one pinned number:

YAML
# .github/workflows/selenium.yml
name: selenium

on:
  push:
  pull_request:
  # Weekly canary so the pin is exercised on a schedule, not only when someone pushes.
  schedule:
    - cron: "0 6 * * 1"

jobs:
  ui:
    runs-on: ubuntu-latest
    env:
      # ONE source of truth for the browser. The driver follows it.
      # Bump this deliberately, in a PR, with the suite green.
      CHROME_VERSION: "151"
      # Keep resolution artifacts inside the workspace so a stale agent cache
      # cannot leak a previous job's driver into this one.
      SE_CACHE_PATH: ${{ github.workspace }}/.selenium-cache
      SE_AVOID_STATS: "true"

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install selenium pytest

      - name: Resolve and record browser + driver
        run: |
          set -euo pipefail
          SM=$(python -c "import selenium, pathlib, sys; \
            p = pathlib.Path(selenium.__file__).parent / 'webdriver' / 'common'; \
            d = {'darwin':'macos','linux':'linux','win32':'windows'}[sys.platform]; \
            print(next((p / d).glob('selenium-manager*')))")
          # --output JSON gives a machine-readable driver_path and browser_path.
          "$SM" --browser chrome \
                --browser-version "$CHROME_VERSION" \
                --output JSON \
                --avoid-stats | tee resolution.json

      - name: Run suite
        run: pytest -q tests/ui

      # Upload the resolution record on failure. When someone asks
      # "which driver ran?", this file is the answer, not a guess.
      - name: Upload resolution record
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: selenium-resolution
          path: resolution.json

The --output JSON mode is documented, and on 0.4.39 it emits a result object containing driver_path and browser_path alongside a logs array. Archiving that one small file turns every future version incident from an argument into a lookup.

The Python side of the pin is two lines:

Python
options = webdriver.ChromeOptions()
options.browser_version = "151"  # -> Selenium Manager --browser-version

browser_version is a property on Selenium's base options class, and DriverFinder forwards it to Selenium Manager as --browser-version. Setting binary_location similarly forwards as --browser-path, which is how you point at a Chrome for Testing build you unpacked into the workspace yourself.

Now the costs, stated plainly, because a fix presented without them is a sales pitch.

You have taken on a maintenance obligation. Chrome ships a new stable major roughly every four weeks. A pinned version that nobody bumps is a pin that gets further from production every month, and the whole point of running these tests is to run them in something like what your users have. Budget a recurring task: bump the number, run the suite, merge. If you use Renovate or Dependabot, wire the bump into it so the PR arrives without a human remembering.

You are now testing a browser your users are not running. This is the real trade-off and it is not free. Chrome for Testing 151 is not identical to the Chrome your customers auto-updated to yesterday. For most functional coverage the difference is irrelevant. For anything touching rendering, media, permissions prompts, or a new web platform feature, it is not, and you should keep a small unpinned canary job on the side that is allowed to fail without blocking merges.

Workspace caches cost time and disk. Setting SE_CACHE_PATH inside the workspace buys isolation between jobs and pays for it with a download on every cold run. On a large fleet that is real minutes and real egress. The alternative, a shared agent cache, is faster and is exactly the thing that produced worked example one. Pick deliberately; do not drift into one by accident.

Some of this is only enforceable where you control the image. On hosted runners you can install a pinned browser into the workspace. On a self-hosted fleet you can additionally freeze the system browser. On a developer laptop you can do neither, so accept that local runs and CI runs may resolve differently and make the diagnostic script above easy to run.

The second failure mode: the mismatch inverts

Everything so far assumes the browser moved ahead of the driver. The reverse happens too, and it reads differently enough to catch people twice.

A driver ahead of its browser occurs when someone updates the driver deliberately, usually while fixing the first kind of mismatch, on a machine where the browser is pinned or simply old. Long-lived Docker base images are the classic host: a Dockerfile that installs Chrome in one layer, gets cached, and then installs "the latest chromedriver" in a later uncached layer will slowly pull the two apart every time the build cache is partially invalidated.

The symptom is the same exception with the numbers the other way round, and the fix is the same principle applied in the other direction: the browser is the anchor, the driver is derived. If you find yourself updating a driver to make an error go away without first checking what browser is installed, you are about to create the inverted mismatch.

There is a related third case worth naming, because it produces the mismatch on a machine that is configured perfectly. A Dockerfile layer pinned as FROM selenium/node-chrome:latest re-resolves on every rebuild. Your Grid was correct on Monday and correct on Friday, but on Friday it was correct about a different Chrome, and the pinned driver in your test image did not follow. Pin container tags to a specific Selenium release, never to latest, for exactly the reason you pin the browser.

Rolling this out without a big-bang week

Doing all of this at once on a large suite is how the change gets reverted. A sequence that works:

Make it observable before you make it different. Add the resolution record step to CI and change nothing else. Let it run for a week. You will very likely discover at least one job resolving a different driver than you assumed, and you will now find out about it from an artifact instead of from an outage.

Consolidate the resolution mechanisms. Grep the repository for executable_path, for webdriver.chrome.driver, for chromedriver in Dockerfiles, and for any se-config.toml you did not know existed. Pick one mechanism for the whole repository and delete the others. This step usually finds the actual root cause on its own, and it is worth doing even if you stop here.

Introduce the pin on one job. Pick the smallest, fastest job you have. Pin it, watch it for a few days, and only then propagate the same CHROME_VERSION variable outward.

Add the bump automation last. A pin without a scheduled bump is a time bomb with a longer fuse. Wire it up once the pin itself is stable, and put the weekly canary job in at the same time so you find out about the next Chrome before it finds out about you.

Write down the version in the incident channel. When this fires again, and it will, the first message should be the two version numbers from the exception. That single habit ends most of these incidents in three minutes.

For related Grid-side failure analysis, the Grid session queue timeout guide covers what happens when session creation never gets far enough to produce this error at all, and Selenium Manager proxy and cache failures covers the resolution step failing outright rather than resolving to the wrong thing.

When not to do this

Pinning is the right default, not a universal law. Four situations where it is the wrong call.

When you are specifically testing browser upgrade risk. Some teams run a deliberate canary suite against Chrome beta or dev so that web platform regressions surface before customers meet them. Pinning that job defeats its only purpose. Selenium Manager accepts beta, dev, and canary as --browser-version values precisely so you can point a job at a moving target on purpose. Keep it separate from the merge gate, and let it fail loudly without blocking anyone.

When the mismatch is a symptom of an unmaintained repository. If your suite is three years old, still on Selenium 3, and pinned to a driver from 2021, adding a better pin is putting a fresh coat of paint on a condemned building. Upgrade the bindings first. Selenium Manager, and most of the tooling in this article, does not exist before 4.6.

When you do not own the browser at all. Running against a cloud Grid provider, a real-device farm, or an enterprise-managed browser fleet means the provider owns the pairing. Requesting a browserVersion there is a routing hint to their scheduler, not a guarantee, and the correct escalation path is the provider's support channel. Building elaborate local pinning around a browser you do not control adds machinery without adding determinism.

When the actual constraint is that CI is too slow to notice. If your suite runs weekly, a version mismatch will sit undetected for six days no matter how well you pin. The higher-leverage fix is a two-minute smoke job that creates one session, prints the two versions, and exits. That catches this class of failure on the day it appears, and it costs less than the pinning infrastructure does.

The common thread across all four: this failure is cheap to detect and cheap to fix once you can see the two version numbers and know which mechanism produced the driver. Almost all the pain comes from not being able to see either.

FAQ

Does the ChromeDriver major version have to match Chrome exactly?

Treat exact major-version matching as the rule you design for, because it is the only arrangement the Chrome team publishes and supports. On one machine running Chrome 151.0.7922.76 I saw ChromeDriver 150.0.7871.115 create a session successfully, while 131 and 125 both refused. That tolerance is undocumented, differs between releases, and is not something to build a pinning policy on.

Why did the error appear even though Selenium Manager is supposed to handle this?

Three common reasons. A driver already on PATH wins before Selenium Manager is consulted. A driver path passed to the Service class skips Selenium Manager entirely, which the Python bindings log as Skipping Selenium Manager; path to chrome driver specified in Service class. And a cached resolution can be reused for the whole TTL window, which defaults to 3600 seconds, so a browser that updated inside that window keeps getting the previous driver.

What is the difference between SessionNotCreatedException and NoSuchDriverException?

They fail at different moments. NoSuchDriverException means the bindings never obtained a driver binary, so nothing was launched; the Python message is Unable to obtain driver for chrome with the real cause chained underneath. SessionNotCreatedException means a driver binary was found, started, and then refused the browser it was pointed at. Only the second one is a version mismatch.

Should I pin the driver version or the browser version?

Pin the browser first and let the driver follow it. The browser is the thing your product actually has to work in, and Chrome for Testing publishes matched browser and driver builds under a single version number, so pinning the browser gives you a driver for free. Pinning only the driver leaves the browser free to move underneath it, which is the exact failure this article is about.

Is retrying the session a reasonable mitigation?

No, and it makes the incident harder to read. A version mismatch is deterministic: the same two binaries produce the same refusal every time, so a retry burns wall-clock time and then reports the same error with a fresh session id. Worse, if the retry runs on a differently provisioned agent and passes, the run goes green while the broken image stays in rotation.

How do I stop Chrome from updating on a CI runner?

Stop using the runner's system Chrome. Hosted images update their browsers on the image maintainer's schedule, not yours, so the durable fix is to install a specific Chrome for Testing build into the workspace and point browser_version or binary_location at it. Blocking the OS updater works on a machine you own, but it does not survive an image refresh.

Practise the diagnosis

Take the reproduction into the QABattle arena and try it from the other side. Given only a stack trace, can you say in one sentence whether the browser moved or the driver moved, and name the command that would prove it? That is the entire skill, and it is worth more than memorising any compatibility table.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Does the ChromeDriver major version have to match Chrome exactly?

Treat exact major-version matching as the rule you design for, because it is the only arrangement the Chrome team publishes and supports. On one machine running Chrome 151.0.7922.76 I saw ChromeDriver 150.0.7871.115 create a session successfully, while 131 and 125 both refused. That tolerance is undocumented, differs between releases, and is not something to build a pinning policy on.

Why did the error appear even though Selenium Manager is supposed to handle this?

Three common reasons. A driver already on PATH wins before Selenium Manager is consulted. A driver path passed to the Service class skips Selenium Manager entirely, which the Python bindings log as 'Skipping Selenium Manager; path to chrome driver specified in Service class'. And a cached resolution can be reused for the whole TTL window, which defaults to 3600 seconds, so a browser that updated inside that window keeps getting the previous driver.

What is the difference between SessionNotCreatedException and NoSuchDriverException?

They fail at different moments. NoSuchDriverException means the bindings never obtained a driver binary, so nothing was launched; the Python message is 'Unable to obtain driver for chrome' with the real cause chained underneath. SessionNotCreatedException means a driver binary was found, started, and then refused the browser it was pointed at. Only the second one is a version mismatch.

Should I pin the driver version or the browser version?

Pin the browser first and let the driver follow it. The browser is the thing your product actually has to work in, and Chrome for Testing publishes matched browser and driver builds under a single version number, so pinning the browser gives you a driver for free. Pinning only the driver leaves the browser free to move underneath it, which is the exact failure this article is about.

Is retrying the session a reasonable mitigation?

No, and it makes the incident harder to read. A version mismatch is deterministic: the same two binaries produce the same refusal every time, so a retry burns wall-clock time and then reports the same error with a fresh session id. Worse, if the retry runs on a differently provisioned agent and passes, the run goes green while the broken image stays in rotation.

How do I stop Chrome from updating on a CI runner?

Stop using the runner's system Chrome. Hosted images update their browsers on the image maintainer's schedule, not yours, so the durable fix is to install a specific Chrome for Testing build into the workspace and point browser_version or binary_location at it. Blocking the OS updater works on a machine you own, but it does not survive an image refresh.