Python 3.10 reached end of life on 1 October 2026. Its final release, 3.10.22, shipped that day with fixes for eight CVEs, and the CPython team will not fix anything in 3.10 again. The interpreter keeps running. What changes is that every future CPython vulnerability stays open on 3.10, and your PyPI dependencies start leaving it one release at a time.
This post is for teams that still run 3.10 in services, containers, CI or Lambda functions. It covers what stopped on 1 October, who still patches 3.10 and until when, how quickly the ecosystem drops an end-of-life Python (with data from the 3.9 end of life a year ago), a script that finds every 3.10 pin in a repository, and the changes that bite when you upgrade.
The short answer
Python 3.10 is end of life as of 1 October 2026. 3.10.22 is its final release and is source-only, so python.org has published no 3.10 installers since 3.10.11. Python 3.11 is now the oldest supported version and receives security fixes until October 2027. 3.12 and 3.13 are also in security-only maintenance, 3.14 is the current bugfix release, and 3.15.0 came out on 9 October 2026. If you are choosing an upgrade target today, pick 3.12 or newer so you aren't doing this again in twelve months.
| Version | Status (python.org, 14 Oct 2026) | End of life |
|---|---|---|
| 3.10 | End of life | 1 Oct 2026 |
| 3.11 | Security fixes only | Oct 2027 |
| 3.12 | Security fixes only | Oct 2028 |
| 3.13 | Security fixes only | Oct 2029 |
| 3.14 | Bugfix | Oct 2030 |
| 3.15 | Bugfix (released 9 Oct 2026) | Oct 2031 |
What the final release fixed
The 3.10.22 release notes list eight CVEs, which is a good picture of what future fixes will look like now that 3.10 won't get them:
- Three
tarfileextraction-filter bypasses (CVE-2026-87910, CVE-2026-82049, CVE-2026-19672). These cover a link falling back to extracting a rejected member, hard links to symlinks reaching outside the destination, and paths that leave the destination and come back. - Unbounded memory use when
zipfiledecompresses small bzip2 and LZMA members (CVE-2026-15310). - Two
sslissues:SSLContext.wrap_bio()not validating its arguments (CVE-2026-19553), and a crash when an SNI callback switches contexts (CVE-2026-19445). stringprepand the IDNA codec accepting code points outside RFC 3454 (CVE-2026-17084).urllib.requestsending HTTPS credentials to matching HTTP URLs (CVE-2026-15806).
There is also a bundled libexpat update to 2.8.5 and hash-flooding hardening for the XML parsers. Archive extraction, TLS and URL handling are exactly the standard-library areas that keep producing CVEs. From now on, fixes in those areas land in 3.11 and later only.
Who still patches 3.10, and until when
Upstream end of life isn't the same as unpatched everywhere. Where your 3.10 comes from decides how exposed you are.
Ubuntu 22.04 LTS. The system python3 on Jammy is 3.10, and Canonical patches it under Ubuntu's own lifecycle, not python.org's. Its latest security upload, 3.10.12-1~22.04.18, was published on 8 September 2026. If your services use the distribution's interpreter on a supported Ubuntu release, the interpreter itself is still covered. Your pip-installed dependencies are not, and that's the next section.
AWS Lambda. The python3.10 runtime runs on Amazon Linux 2. AWS's runtime table lists deprecation on 31 October 2026, function creation blocked from 29 July 2027, and updates blocked from 31 August 2027. AWS says the runtime keeps receiving patches for critical and selected important Amazon Linux 2 security issues, plus language runtime patches, until that deprecation date. After it, no more patches.
Docker official images. The docker-library/python repository now builds 3.11 through 3.15 only. The python:3.10 tag on Docker Hub was last updated on 6 October 2026, and with 3.10 out of the build list it won't be rebuilt. Images built FROM python:3.10 from here on get neither interpreter fixes nor Debian base-image fixes through that tag.
python.org builds, pyenv, uv python install and setup-python. These all give you upstream CPython, so 3.10.22 is the end of the line.
How fast your dependencies will leave
The interpreter is half the problem. The other half is that popular libraries drop support for a Python version soon after it reaches end of life. Once a library's latest release won't install on your interpreter, pip quietly resolves to the newest release that does. Security fixes published after that point never reach you, and nothing in a normal pip install tells you so.
We measured this for the 200 most-downloaded PyPI projects (30-day downloads as of 1 October 2026, from the top-pypi-packages dataset), reading each project's release metadata from PyPI and known vulnerabilities from OSV. A project "drops" a Python version when its latest release declares a Requires-Python that excludes it, or ships compiled wheels for other versions with no wheel or source distribution for it.
Two weeks after its end of life, 3.10 looks fine:
Top 200 PyPI projects whose latest release no longer installs on Python 3.10: 18
...of which the newest release that does install has known vulnerabilities fixed only later: 0 projects, 0 vulnerabilities
Eighteen projects have moved on, including numpy, pandas, scipy, scikit-learn, SQLAlchemy and Starlette, but none has a vulnerability that 3.10 users can't get a fix for. Yet.
Python 3.9 reached end of life on 31 October 2025. Here is the same measurement for it, eleven and a half months later:
Top 200 PyPI projects whose latest release no longer installs on Python 3.9: 112
...of which the newest release that does install has known vulnerabilities fixed only later: 25 projects, 78 vulnerabilities
urllib3 newest for 3.9: 2.6.3 latest: 2.8.0 5 stranded, e.g. CVE-2026-97687, CVE-2026-97688
requests newest for 3.9: 2.32.5 latest: 2.34.2 1 stranded, e.g. CVE-2026-25645
setuptools newest for 3.9: 82.0.1 latest: 84.0.0 1 stranded, e.g. CVE-2026-59890
anyio newest for 3.9: 4.12.1 latest: 4.15.1 2 stranded, e.g. CVE-2026-64847, CVE-2026-63374
pytest newest for 3.9: 8.4.2 latest: 9.1.1 1 stranded, e.g. CVE-2025-71176
python-dotenv newest for 3.9: 1.2.1 latest: 1.2.4 1 stranded, e.g. CVE-2026-28684
fsspec newest for 3.9: 2025.10.0 latest: 2026.9.0 1 stranded, e.g. CVE-2026-104851
filelock newest for 3.9: 3.19.1 latest: 4.1.1 2 stranded, e.g. CVE-2026-22701, CVE-2025-68146
starlette newest for 3.9: 0.49.3 latest: 1.8.0 5 stranded, e.g. CVE-2026-54283, CVE-2026-48710
multidict newest for 3.9: 6.7.1 latest: 7.1.0 1 stranded, e.g. CVE-2026-104874
aiohttp newest for 3.9: 3.13.5 latest: 3.14.5 14 stranded, e.g. CVE-2026-54279, CVE-2026-54273
pillow newest for 3.9: 11.3.0 latest: 12.3.0 18 stranded, e.g. CVE-2026-55379, CVE-2026-55798
pip newest for 3.9: 26.0.1 latest: 26.2.1 4 stranded, e.g. CVE-2026-3219, CVE-2026-6357
python-multipart newest for 3.9: 0.0.20 latest: 0.0.32 7 stranded, e.g. CVE-2026-53539, CVE-2026-53538
pyarrow newest for 3.9: 21.0.0 latest: 26.0.0 1 stranded, e.g. CVE-2026-25087
soupsieve newest for 3.9: 2.8.4 latest: 3.0.3 2 stranded, e.g. CVE-2026-86000, CVE-2026-85999
httpx2 newest for 3.9: 2.0.0 latest: 2.13.1 3 stranded, e.g. CVE-2026-84382, CVE-2026-84379
httpcore2 newest for 3.9: 2.0.0 latest: 2.13.1 1 stranded, e.g. CVE-2026-84381
msgpack newest for 3.9: 1.1.2 latest: 1.2.3 1 stranded, e.g. CVE-2026-57585
mako newest for 3.9: 1.3.12 latest: 1.4.3 1 stranded, e.g. CVE-2026-102991
orjson newest for 3.9: 3.11.5 latest: 3.13.0 1 stranded, e.g. CVE-2025-67221
langchain newest for 3.9: 0.3.30 latest: 1.4.5 1 stranded, e.g. CVE-2026-55443
h2 newest for 3.9: 4.3.0 latest: 4.4.1 1 stranded, e.g. CVE-2026-71554
hpack newest for 3.9: 4.1.0 latest: 4.2.0 1 stranded, e.g. CVE-2026-59980
langchain-core newest for 3.9: 0.3.86 latest: 1.6.10 2 stranded, e.g. CVE-2026-26013, CVE-2026-34070
A year after end of life, more than half of the top 200 projects had dropped 3.9, and 25 of them leave 3.9 users on a release with known vulnerabilities that are fixed only in releases they can't install. Several are on the request path of almost every Python web service. urllib3's last release for 3.9 is 2.6.3. Five CVEs in it are fixed only in 2.7 and later, which require Python 3.10. Three of them were published on 30 September 2026, eleven months after 3.9 left support, and every 3.9 environment that resolves urllib3 today is still exposed to them. aiohttp, Pillow, Starlette and python-multipart follow the same pattern.
That is the realistic shape of the next year for 3.10. The count of stranded fixes starts at zero and climbs every time a project that has already dropped 3.10 publishes a security release.
A few limits on these numbers. A distribution may backport a fix into its own package of a library, so a Debian or Ubuntu python3-urllib3 can be safer than the PyPI version with the same number. OSV is only as complete as its sources, and some projects may still publish fixes on an older branch in future. The top 200 skews towards infrastructure libraries, which are maintained more actively than average.
The script is below. Pass it a different version to see where 3.11 stands, or raise the count to cover more of the index.
#!/usr/bin/env python3
"""For the most-downloaded PyPI projects: has the latest release dropped a given
Python version? If so, what is the newest release that still installs on it,
and which known vulnerabilities in that release are fixed only in releases
that no longer support it? Needs: pip install packaging
Data: https://hugovk.github.io/top-pypi-packages/top-pypi-packages-30-days.min.json
Usage: python3 stranded.py top-pypi-packages.json 3.10 [N=200]"""
import json, sys, urllib.request
from concurrent.futures import ThreadPoolExecutor
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.version import Version, InvalidVersion
top, pyver = sys.argv[1], sys.argv[2]
N = int(sys.argv[3]) if len(sys.argv) > 3 else 200
PY = Version(pyver + ".99") # any patch release of that minor
TAG = "-cp" + pyver.replace(".", "") + "-" # e.g. -cp310-
def get(url, body=None):
req = urllib.request.Request(url, data=body, headers={
"User-Agent": "stranded-check", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def installs_on(files):
files = [f for f in files if not f.get("yanked")]
if not files:
return False
try:
rp = files[0].get("requires_python") or ""
if rp and PY not in SpecifierSet(rp):
return False
except InvalidSpecifier:
return False
native = [f["filename"] for f in files if "-cp3" in f["filename"] and "abi3" not in f["filename"]]
has_sdist = any(f["packagetype"] == "sdist" for f in files)
return has_sdist or not native or any(TAG in w for w in native)
def check(name):
meta = get(f"https://pypi.org/pypi/{name}/json")
latest = meta["info"]["version"]
if installs_on(meta["releases"].get(latest, [])):
return None
rels = []
for v, files in meta["releases"].items():
try:
if not Version(v).is_prerelease:
rels.append((Version(v), v, files))
except InvalidVersion:
pass
last = next((v for pv, v, f in sorted(rels, reverse=True) if installs_on(f)), None)
stranded = []
if last:
q = get("https://api.osv.dev/v1/query", json.dumps(
{"package": {"name": name, "ecosystem": "PyPI"}, "version": last}).encode())
seen = set()
for v in q.get("vulns", []):
ids = {v["id"], *v.get("aliases", [])}
if ids & seen:
continue # same issue under another ID
seen |= ids
fixed = [e["fixed"] for a in v.get("affected", [])
for r in a.get("ranges", []) for e in r.get("events", []) if "fixed" in e]
if any(Version(f) > Version(last) for f in fixed if f[0].isdigit()):
stranded.append(next((i for i in sorted(ids) if i.startswith("CVE-")), v["id"]))
return {"name": name, "latest": latest, "last": last, "stranded": stranded}
names = [r["project"] for r in json.load(open(top))["rows"][:N]]
with ThreadPoolExecutor(12) as ex:
dropped = [r for r in ex.map(check, names) if r]
hit = [r for r in dropped if r["stranded"]]
print(f"Top {N} PyPI projects whose latest release no longer installs on Python {pyver}: {len(dropped)}")
print(f"...of which the newest release that does install has known vulnerabilities fixed only later: "
f"{len(hit)} projects, {sum(len(r['stranded']) for r in hit)} vulnerabilities")
for r in hit:
print(f" {r['name']:<18} newest for {pyver}: {r['last']:<10} latest: {r['latest']:<10} "
f"{len(r['stranded'])} stranded, e.g. {', '.join(r['stranded'][:2])}")
Find every 3.10 pin in a repository
Python versions hide in more places than pyproject.toml. This script walks a repository and reports 3.10 or older in packaging metadata, .python-version and runtime.txt, Dockerfile base images, CI workflow matrices, tox and nox environments, and Lambda runtimes in SAM, Serverless, CloudFormation and Terraform files. It uses only the standard library, and exits non-zero when it finds something, so it can run as a CI check.
#!/usr/bin/env python3
"""Find places in a repository that pin an end-of-life Python (3.10 or older by
default): pyproject/setup.cfg, .python-version, runtime.txt, Dockerfiles, CI
workflows, tox, and AWS Lambda/SAM/Serverless/Terraform runtimes.
Usage: python3 py_eol_scan.py [repo_dir] [--max-eol 3.10]"""
import re, sys, pathlib
args = sys.argv[1:]
max_eol = (3, 10)
if "--max-eol" in args:
i = args.index("--max-eol"); max_eol = tuple(map(int, args[i + 1].split("."))); del args[i:i + 2]
root = pathlib.Path(args[0] if args else ".")
SKIP = {".git", "node_modules", ".venv", "venv", ".tox", "__pycache__", "site-packages"}
PATTERNS = [ # (file glob test, regex with a (major).(minor) group pair, label)
(lambda p: p.name in ("pyproject.toml", "setup.cfg", "setup.py"),
r"(?:requires-python|python_requires)\s*=\s*['\"]?[^'\"\n]*?>=\s*(\d)\.(\d+)", "minimum supported Python"),
(lambda p: p.name in (".python-version", "runtime.txt"),
r"^(?:python-)?(\d)\.(\d+)", "pinned interpreter"),
(lambda p: p.name.startswith("Dockerfile") or p.suffix == ".dockerfile",
r"^\s*FROM\s+(?:\S*/)?python:(\d)\.(\d+)", "container base image"),
(lambda p: p.suffix in (".yml", ".yaml") and (".github" in p.parts or ".gitlab-ci" in p.name or "azure-pipelines" in p.name),
r"(?<![\d.])(3)\.(\d+)(?![\d])", "CI matrix / setup-python"),
(lambda p: p.name in ("tox.ini", "noxfile.py"),
r"py(3)(\d{2})\b", "tox/nox environment"),
(lambda p: p.suffix in (".yml", ".yaml", ".json", ".tf"),
r"(?:Runtime|runtime)\s*[:=]\s*['\"]?python(\d)\.(\d+)", "AWS Lambda runtime"),
]
hits = 0
for path in sorted(root.rglob("*")):
if not path.is_file() or SKIP & set(path.parts):
continue
for test, rx, label in PATTERNS:
if not test(path):
continue
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for n, line in enumerate(text.splitlines(), 1):
if label.startswith("CI") and not re.search(r"python(-version)?\s*:", line, re.I):
continue
for m in re.finditer(rx, line, re.I):
ver = (int(m.group(1)), int(m.group(2)))
if ver[0] == 3 and ver <= max_eol:
hits += 1
print(f"{path.relative_to(root)}:{n}: Python {ver[0]}.{ver[1]} ({label}) {line.strip()[:80]}")
print(f"\n{hits} reference(s) to end-of-life Python (<= {max_eol[0]}.{max_eol[1]})")
sys.exit(1 if hits else 0)
We ran it against a test repository with a service, a SAM template and CI configuration:
$ python3 py_eol_scan.py fx
fn/template.yaml:5: Python 3.10 (AWS Lambda runtime) Runtime: python3.10
svc-a/Dockerfile:1: Python 3.10 (container base image) FROM python:3.10-slim
svc-a/pyproject.toml:3: Python 3.10 (minimum supported Python) requires-python = ">=3.10"
svc-b/.github/workflows/ci.yml:5: Python 3.10 (CI matrix / setup-python) python-version: ["3.10", "3.11", "3.12"]
svc-b/.github/workflows/old.yml:5: Python 3.9 (CI matrix / setup-python) python-version: ["3.12", "3.9", "3.10"]
svc-b/.github/workflows/old.yml:5: Python 3.10 (CI matrix / setup-python) python-version: ["3.12", "3.9", "3.10"]
svc-b/tox.ini:2: Python 3.10 (tox/nox environment) envlist = py310, py312
7 reference(s) to end-of-life Python (<= 3.10)
Two notes on reading the results. A requires-python = ">=3.10" line in a library you publish isn't a problem in itself. It means you still claim to support 3.10, which you can keep doing as long as your CI tests it. In an application, that line usually means nobody has bumped it. And --max-eol 3.9 narrows the scan to versions that have been out of support for a year.
Upgrade traps between 3.10 and 3.12+
Most code moves from 3.10 to 3.12 without changes. The common breakages are removals and one TLS change from the final 3.10 release:
distutilsis gone in 3.12 (PEP 632). Oldsetup.pyfiles and build scripts that import it need setuptools or a modern build backend.imp,asynchatandasyncoreare removed in 3.12. Useimportlibandasyncio.- 3.13 removes 19 "dead batteries" (PEP 594), including
cgi,crypt,telnetlib,pipesandimghdr. Grep for them before choosing 3.13 as your target. - TLS hostnames get stricter. The 3.10.22 notes say a missing
server_hostnamewithcheck_hostnameenabled now raises aDeprecationWarningon 3.10 and 3.11, and aValueErroron 3.13 and later. Code that builds TLS connections throughwrap_bio()or asyncio without a hostname will fail after the upgrade, which is the right outcome but needs a fix first. - Compiled dependencies need new wheels. Pinned old versions of numpy, pandas or pyarrow may have no wheels for 3.12+, which turns an interpreter upgrade into a dependency upgrade. Do both in the same change, with the lockfile regenerated on the new interpreter.
The counter-argument, and what to do this month
Running 3.10 for a while longer is defensible in one case. On Ubuntu 22.04's system interpreter, with dependencies that come from the distribution rather than PyPI, Canonical's patches cover both. Most services don't work that way. They install from PyPI into a virtualenv or container, which is where the data above says the exposure builds up.
For everyone else, the order of work is short:
- Run the scanner across your repositories and list every 3.10 (and older) pin, including Lambda runtimes. The 31 October Lambda deprecation is the nearest hard date.
- Move
FROM python:3.10images first, because that tag no longer receives rebuilds. - Upgrade services to 3.12 or newer, fixing the removals above and regenerating lockfiles on the new interpreter.
- Until a service moves, treat its Python version as a finding in its own right. A dependency scan of a 3.10 environment checks the versions you have, not the fixes you can no longer install.
If you need to answer "which services, across all our repositories, still pin 3.10?" in one place rather than repository by repository, that's the kind of inventory Safeguard SCA keeps. The scripts above will get you most of the way on their own. For the wider practice of tracking end-of-life components, see our guide to managing end-of-life software dependencies.