Python 3.10 End of Life: The October 2026 Upgrade Playbook
Security patches stop on 31 October 2026, but Ubuntu 22.04 stays supported until April 2027 and Lambda keeps accepting updates until March. Four deadlines that do not line up, and the two that actually bite.
Python 3.10 stops getting security patches on 31 October 2026, about ten weeks out. The reason this EOL matters more than the last few is boring: 3.10 is the default interpreter on Ubuntu 22.04 LTS. Every FROM ubuntu:22.04 image, every EC2 instance nobody has touched since 2023, every python:3.10-slim base goes unpatched on the same day.
What makes it confusing is that three other deadlines sit nearby and none of them match. Dates below came off the Python developer guide, the AWS Lambda runtime docs, and Canonical’s release cycle page on 21 August 2026.
That five month gap is where the risk lives. Ubuntu Pro with ESM stretches 22.04 to 2032, but Canonical backporting fixes into their packaged 3.10 is not the same as upstream CPython maintaining it, and I would not assume equivalent coverage beyond the interpreter itself.
Lambda has its own clock, and it is more forgiving than people think
The python3.10 Lambda runtime is deprecated on 31 October 2026. That is not when your functions stop working. AWS publishes three separate dates: deprecation on 31 October, blocking new function creation on 1 February 2027, and blocking code and config updates on 3 March 2027. Existing functions keep being invoked indefinitely after that.
So you have until March to change the runtime string, and after that you can still move forward to a supported runtime but rolling back to 3.10 may be blocked.
One detail worth knowing: the python3.10 and python3.11 Lambda runtimes are built on Amazon Linux 2, which reached end of life on 30 June 2026. AWS is patching selected critical AL2 issues on those runtimes until their deprecation dates, but you are on an EOL operating system underneath an EOL interpreter. Find them:
aws lambda list-functions \
--query 'Functions[?Runtime==`python3.10`].[FunctionName,LastModified]' \
--output table
Audit before you plan
The count of 3.10 runtimes is always higher than the team’s guess, because one repository routinely holds three of them.
Container images are first. Grep Dockerfiles for 3.10, but also the bases you inherit: FROM ubuntu:22.04 plus apt-get install python3 gives you 3.10 without ever naming it.
rg -n 'python:3\.10|ubuntu:22\.04|python3\.10' --glob 'Dockerfile*' --glob '*.yml'
Then CI matrices with python-version: "3.10", which are the easiest to fix and the easiest to forget. Then managed runtimes: Lambda, Cloud Run, App Engine, Azure Functions, Databricks, Glue. Then requires-python in every pyproject.toml, which tells you which of your own libraries still advertise 3.10 and are therefore still tested against it.
Write the result down as a list of runtimes, not repositories.
3.12 or 3.13
Support windows are the only part of this that is fact rather than preference.
| Version | Security support ends |
|---|---|
| 3.11 | October 2027 |
| 3.12 | October 2028 |
| 3.13 | October 2029 |
| 3.14 | October 2030 |
Skip 3.11, which buys a year. Skip 3.15 as a migration target: it is scheduled for 1 October 2026, the same month 3.10 dies, and being first on a fresh minor while mid-migration means debugging two things at once. On Lambda it is not even an option yet, since python3.15 is in public preview with a target launch of November 2026.
That leaves 3.12 or 3.13. The usual advice is that 3.12 is the safe pick, and for a large dependency tree under deadline pressure it is: two years after release, every dependency worth using has 3.12 wheels, and it is what most vendor runtimes standardized on.
But if you are on Lambda, check the runtime table before defaulting to it. The python3.12 runtime is deprecated on 31 October 2028, while python3.13 and python3.14 both run to 30 June 2029. On Lambda specifically, 3.13 costs the same migration and buys eight more months. That inverts the usual recommendation.
The tradeoff against 3.13 is stdlib removals, covered below. It is a static check, so you can settle the question in a minute rather than arguing about it.
What actually breaks
The language barely moved. Code that runs on 3.10 almost always runs on 3.12 unmodified. Breakage concentrates in three places.
distutils is gone in 3.12. Deprecated in 3.10, removed outright. Anything doing from distutils.core import setup or from distutils.util import strtobool dies at import.
Your own code is easy. The problem is distutils imports hiding in the setup.py of transitive dependencies, which only surface when something builds from source, which is exactly what happens on a platform with no prebuilt wheel. The failure mode is: fine on your Mac, broken on the ARM runner.
Modern setuptools still vendors a shim, so this often makes it disappear:
RUN pip install --no-cache-dir "setuptools>=69" && pip install -r requirements.txt
That is a bandage. The fix is upgrading the dependency or replacing your own usage, and most calls have one-line replacements: strtobool is four lines of your own code, distutils.spawn.find_executable is shutil.which, version comparison is packaging.version.Version. imp also went in 3.12; use importlib. PEP 594 also took asynchat, asyncore, and smtpd in 3.12, which catches older mail and socket code.
Nineteen more stdlib modules in 3.13. PEP 594 finished the job: aifc, audioop, chunk, cgi, cgitb, crypt, imghdr, mailcap, msilib, nis, nntplib, ossaudiodev, pipes, sndhdr, spwd, sunau, telnetlib, uu, and xdrlib.
Most teams touch none of them. The ones that turn up in real code are cgi (old WSGI multipart parsing), crypt (use passlib or bcrypt), telnetlib (network device automation, genuinely painful), and imghdr (use filetype or Pillow). Check statically, across your source and your installed site-packages:
rg -n '^\s*(import|from)\s+(aifc|audioop|chunk|cgi|cgitb|crypt|imghdr|mailcap|msilib|nis|nntplib|ossaudiodev|pipes|sndhdr|spwd|sunau|telnetlib|uu|xdrlib)\b'
Clean means 3.13 is close to free and you should take the extra year.
C extensions need rebuilding. psycopg2, lxml, numpy, pydantic-core, cryptography are built against a specific CPython ABI. Usually invisible because pip fetches the right wheel. It becomes a problem only when a package never published one for your target and pip falls back to building from source.
The dependency landmine
Migrations rarely fail on your code. They fail on the one unmaintained package pinned in requirements.txt that never shipped a 3.12 wheel and whose setup.py imports distutils.
Find those first, in a throwaway container:
docker run --rm -v "$PWD:/app" -w /app python:3.12-slim \
sh -c 'pip install --dry-run -r requirements.txt' 2>&1 | tail -40
--dry-run resolves without installing, so it is fast. Run it against 3.13 too and compare. That comparison often picks the version for you.
For anything unresolvable: upgrade the package, replace it, or vendor and patch it, in that order of preference. If a package’s last release predates 2023 and its 3.12 issue has no maintainer response, treat it as abandoned and plan the replacement now rather than in October.
The mechanical part
pyupgrade --py312-plus or ruff’s UP rules rewrite legacy idioms. Optional for correctness, since 3.10 syntax is valid 3.12 syntax, but a reasonable moment to do it.
Run both interpreters side by side before cutting over. uv fetches interpreters itself, so this needs no pyenv and no system packages:
uv run --python 3.10 pytest
uv run --python 3.12 pytest
I compared it against the alternatives in uv vs Poetry vs pip vs PDM; for this job it is the only one that will pull down two interpreters without setup.
Then turn on the warnings you have been ignoring. Run the suite with -W error::DeprecationWarning on 3.10 first. Today’s deprecation warnings are the removals two releases from now, and clearing them is the cheapest insurance against repeating this in 2028.
For Docker, rebuild without cache, because a cached layer will hide an install failure until the next clean CI build. For CI, add the new version alongside 3.10 rather than replacing it, get green on both, then drop 3.10.
Rollout
Dev, then CI, then one canary service, then the rest. The piece most often skipped is the rollback, and for containers it is easy: keep the previous image tag and write down the exact redeploy command. If the plan is “rebuild from the previous commit,” that is not a rollback, it is a fifteen minute outage.
Watch for what tests miss: C extension crashes under load, asyncio timing differences, and behavior changes in the forty packages you also bumped while you were in there. Which is the other rule. Do not upgrade the interpreter and the dependency tree in one pull request, or you will not know which one broke it.
If October is genuinely not achievable
Then containment is the plan, and containment means specifics. Take the service off the public internet. If it must be reachable, terminate and inspect requests before Python sees them. Freeze its dependencies. Accept that a CPython CVE in November is yours to handle.
Commercial post-EOL vendors will backport fixes to 3.10 for a fee. That is a real option when a compliance rule demands a patched interpreter and the migration genuinely cannot finish. It is a bad option when it is just buying another year of not doing the work, because the contract will cost more than the upgrade would have.
Pick one repository this week and run the four audit checks, then the --dry-run install against python:3.12-slim. That is about an hour, and it turns “we should look at Python 3.10 sometime” into a list with a length. The list is almost always shorter than the dread attached to it.