To upgrade the Python version of a project is to run the same code on a newer Python, for example moving from Python 3.12 to Python 3.15. The code does not change. The interpreter that runs it does, and some of the modules and functions the code calls may no longer exist in the new one. That is where the work is, and a minor upgrade (3.12 to 3.15) is the one that has it; a patch upgrade (3.14.6 to 3.14.8) only brings fixes and needs no code change.
The whole upgrade comes down to one rule: find and fix what the new Python breaks while you are still on the old one. Python warns about every removal at least two years before it happens, so one test run on the current version, with warnings turned into errors, lists the calls that will fail after the switch:
python3.12 -W error -m unittest discover -s tests -t .
# DeprecationWarning: 'cgi' is deprecated and slated for removal in Python 3.13
Each line of that output is one call to fix, with a stack trace to the line in our own code, and we fix it on a project that still works.
This article covers a project that already runs on Python 3 and moves to a newer Python 3 release, with the tools that come with Python, pip and venv. If your project is still on Python 2.7, this is not the article for you, because moving from Python 2 to Python 3 is a different job with different tools. Section 3 lists the seven steps, each with its command and the result you have when it is done, and sections 4 to 10 carry them out on a small example project, moving it from 3.12 to 3.15.
1. When to Upgrade, and to Which Version
Python versions are numbered major.minor.patch, as in 3.15.0. A new minor version comes out every October. Patch versions follow for five years: bugfix releases for the first two years, security fixes only for the last three, and then nothing. That last point, called end of life, is the usual reason to upgrade. A project on a version past end of life runs fine and gets no more security fixes. As of 2026-10-17, Python 3.10 is past end of life, 3.11 to 3.13 get security fixes only, and 3.14 and 3.15 still get bugfix releases. The other reason is a library you need dropping support for the version you run.

Pick the newest version on which all your dependencies install. Python 3.15.0 came out on 2026-10-09, and many libraries take a few months to support a brand-new version (section 8 shows how to check yours). So a project with several such libraries often moves to 3.14 now and to 3.15 later; 3.14 is supported until October 2030, so nothing is lost. Jumping several versions at once is fine, as long as you read the release notes of every version in between.
1.1. Installing the New Python Next to the Old One
The upgrade needs both versions on the machine until the last step. Each version has its own interpreter, which is the program named python3.12 or python3.15 that runs your code, and installing a new one leaves the old one alone.
- On Windows and macOS, download the 3.15 installer from python.org and run it. On Windows, py -3.15 then starts the new version and py -3.12 the old one.
- On Linux, install the python3.15 package from your distribution if it has one, and otherwise the installer from python.org.
- With uv, a newer tool that does the job of pip and venv in one program and also installs Python versions, uv python install 3.15 adds it in one command. The rest of this article shows the uv form next to the pip form where it is shorter; it is optional.
2. The Example Project
Recipe Box is a small library with four helpers. They evaluate quantity expressions such as 1/2 * 3, read upload headers, stamp save times and load recipes. Two further modules use Pillow (an image library) to shrink recipe photos and PyYAML (a YAML parser) to read recipe files. The dependencies are listed in requirements.txt, and there is a unittest suite of six tests.
The project is in the python-upgrade-guide folder on GitHub, as a before folder and an after folder. The before folder passes its six tests on Python 3.12.15 with Pillow 10.4.0 and PyYAML 6.0.1, and four of its helpers call code that Python 3.13 or 3.14 removes or changes. A script named probe.py calls each helper and prints one result line, so we can see all four at once.
Some tools in this article read their settings from pyproject.toml, a settings file that many Python tools share. Recipe Box does not need one, and for each such tool we also show the tool’s own small file.
3. The Seven Steps
Each step has a command and a result, and the step is done when you have the result. Steps 2 to 4 run on the old version on purpose: a problem found there is fixed on a project that still works, before the new Python is installed anywhere near production. The old version stays in use until step 7, so at any point going back is a matter of configuration, not code.

| Step | Command | What you have afterwards |
|---|---|---|
| 1. Read the release notes | Open What’s New in Python 3.x for every version after the current one (section 4) | A list of the removals and behavior changes that touch the modules your code imports |
| 2. Make the tests pass on the old version | python3.12 -m unittest discover -s tests -t . (section 5) | A green run on 3.12. Every later step uses it to tell an upgrade problem from an old bug |
| 3. Turn warnings into errors | python3.12 -W error -m unittest discover -s tests -t . (section 6) | The calls in your own code that the new version will break, each with a stack trace |
| 4. Fix what the warnings name | Edit the code, one warning at a time (section 7) | The step 3 command passing on 3.12. The code is ready for the new version; the packages may not be |
| 5. Check the dependencies | pip install –only-binary=:all: -r requirements.txt in the 3.15 environment (section 8) | A requirements.txt that installs on 3.15, or the name of the package that cannot |
| 6. Test on both versions | The step 3 command in the 3.12 environment and in the 3.15 environment (section 9) | Green runs on both. 3.12 proves the fixes broke nothing, 3.15 proves the upgrade works |
| 7. Switch the runtime | Recreate the server environment on 3.15, or change the Docker image, the CI image and requires-python (section 10) | The app running on 3.15 in production, with the 3.12 environment kept for a rollback |
Rewriting old syntax with a tool is not one of the steps. It is optional cleanup, it finds only one of the four Recipe Box problems, and section 11 covers it.
3.1. One Virtual Environment per Python Version
A virtual environment is a folder that holds one Python interpreter plus the packages installed for one project, and it belongs to one Python version. Steps 2 to 7 need two of them, one per version. Create both now. The dependencies go into the 3.12 one right away; installing them into the 3.15 one is step 5, because that install is the dependency check.
python3.12 -m venv .venv312 # old version
python3.15 -m venv .venv315 # new version, installed in section 1.1
.venv312/bin/pip install -r requirements.txt # on Windows: .venv312\Scripts\pip install -r requirements.txt
With uv, the first two lines are uv venv –python 3.12 .venv312 and uv venv –python 3.15 .venv315. From here on, python3.12 -m unittest … is short for .venv312/bin/python -m unittest …, the interpreter inside that environment.
4. Step 1. Read the Release Notes
This step ends with a list of the changes that touch our code. The release notes are the official record of what changed in a Python version, one page per minor version, titled What’s New in Python 3.x. Three chapters of that page matter, and we read them in this order.
- The Removed chapter lists what no longer exists in this version. This is the code that will stop importing.
- The Porting chapter lists changes in behavior. This is code that still imports and then returns something different.
- The Deprecated chapter lists what Python will remove in later versions. This is the work list for the next upgrade, not this one.
For a jump across several versions, read the notes of every version after your current one, and search each page for the modules your code imports. Most removals name their replacement on the same page. A behavior change is harder to spot, because there is no import to search for. For example, the way multiprocessing starts its worker processes changed in Python 3.14 with no rename at all, and the page offers a workaround instead of a replacement.
The table has one row per change that breaks a typical project between Python 3.11 and 3.15. Start at the row for your own version.
| Python | Change | Symptom | Fix |
|---|---|---|---|
| 3.11 | The decorator @asyncio.coroutine is removed | AttributeError | Write async def functions |
| 3.11 | inspect.getargspec() is removed | AttributeError | Call inspect.signature() |
| 3.11 | The module binhex is removed | ModuleNotFoundError | Use binascii or the base64 module |
| 3.12 | The package distutils is removed | ModuleNotFoundError | Install setuptools, which provides it |
| 3.12 | unittest aliases such as assertEquals() are removed | AttributeError | Call assertEqual() |
| 3.12 | The modules asyncore, asynchat, imp and smtpd are removed | ModuleNotFoundError | Use asyncio and importlib |
| 3.13 | 19 legacy modules from PEP 594 are removed, for example cgi, imghdr, telnetlib and crypt | ModuleNotFoundError | Use the email package for cgi.parse_header(), or a PyPI package such as standard-cgi, which is a copy of the removed module |
| 3.14 | ast.Num, ast.Str, ast.Bytes, ast.NameConstant and ast.Ellipsis are removed | AttributeError, or TypeError when a NodeVisitor defines visit_Num() | Use ast.Constant |
| 3.14 | asyncio.get_event_loop() no longer creates an event loop | RuntimeError | Call asyncio.run() |
| 3.14 | forkserver is the default start method of multiprocessing and ProcessPoolExecutor on Linux | NameError or PicklingError, without a warning in 3.13 | Define worker functions at module level, or pass the fork context |
| 3.14 | Annotations are evaluated when they are read | Different values from __annotations__, without a warning | Call annotationlib.get_annotations() |
| 3.14.0 to 3.14.4 | The incremental garbage collector uses more memory in production | Higher memory use | Update to 3.14.5 or later, which reverts it |
| 3.15 | UTF-8 is the default encoding of open() | Different text on Windows, without an error | Pass an encoding argument |
| 3.15 | The modules sre_compile, sre_constants and sre_parse are removed | ModuleNotFoundError | Import re |
| 3.15 | The functions glob.glob0() and glob.glob1() are removed | AttributeError | Call glob.glob() with root_dir |
Three rows print nothing before the change: the start method, the annotations and the encoding. Only tests on the new Python will find those, which is what step 6 is for. When our code depends on the old start method, we name it:
import multiprocessing
from concurrent.futures import ProcessPoolExecutor
def scale(servings):
return servings * 2
with ProcessPoolExecutor(mp_context=multiprocessing.get_context("fork")) as pool: # keeps the old start method
doubled = list(pool.map(scale, [2, 4])) # doubled = [4, 8]
5. Step 2. Make the Tests Pass on the Old Version
This step ends with a green test run on the version you have today, and it exists so that every failure from step 3 on is an upgrade problem and not an old one. Run the suite on 3.12 and fix whatever fails before going further:
python3.12 -m unittest discover -s tests -t . # Ran 6 tests, OK
A test that is already failing today will fail on the new version too, and you would spend the upgrade chasing it. Note also that a warning in step 3 only appears when the deprecated call runs, so the more of your code the tests reach, the more the next step finds.
6. Step 3. Turn Warnings Into Errors
This step ends with the list of calls in our own code that the new version will break, found on the old one. A deprecation warning is the message Python prints when code calls something that will be removed in a later version, and by the deprecation policy in PEP 387 (a PEP, or Python Enhancement Proposal, is the document Python uses to record a rule) a feature stays deprecated for at least two years before it goes. So the warnings on 3.12 already name most of what breaks on 3.14 and 3.15.
The warnings are easy to miss. By default, Python only shows a DeprecationWarning when the deprecated call is in the script we ran directly; a call inside one of our own modules or inside a library stays silent. Test runners such as unittest and pytest print them, but in a summary at the end of the run that scrolls past, so a green run can have a page of them behind it.
| Where the deprecated call is | python script.py | python -m unittest | python -W error -m unittest |
|---|---|---|---|
| In the script we run | Warning printed | Warning in the end-of-run summary | The test fails, with a stack trace |
| In an imported module or a library | Nothing | Warning in the end-of-run summary | The test fails, with a stack trace |
The last column is what we want: every warning becomes an error, the deprecated call fails the test like any other bug, and the stack trace points at the line in our code that made it. There are three ways to switch it on, and they differ in how far the setting reaches.
- The interpreter option -W error sets it for one run.
- The environment variable PYTHONWARNINGS=error sets it for every Python process started while the variable is set.
- The pytest setting filterwarnings sets it for every pytest run of the project.
python3.12 -W error -m unittest discover -s tests -t . # unittest, one run
PYTHONWARNINGS=error python3.12 -m pytest # any Python command, here pytest
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = ["error"]
# pytest.ini, for a project without pyproject.toml
[pytest]
filterwarnings = error
On Recipe Box, probe.py runs each helper with warnings as errors and prints one line per helper. On Python 3.12 it already names all four problems, including the cgi import that a plain test run never mentions:
Python 3.12.15
quantity DeprecationWarning: visit_Num is deprecated; add visit_Constant
clock DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version
runner DeprecationWarning: There is no current event loop
upload DeprecationWarning: 'cgi' is deprecated and slated for removal in Python 3.13
Run the same script on Python 3.14.8 and you see what those warnings turn into when nobody acts on them. Three helpers fail outright. Note that the quantity error says nothing about visit_Num(), so the warning back on 3.12 was the more useful message of the two:
Python 3.14.8
quantity TypeError: unsupported operand type(s) for /: 'NoneType' and 'NoneType'
clock DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version
runner RuntimeError: There is no current event loop in thread 'MainThread'.
upload ModuleNotFoundError: No module named 'cgi'
Two things come up at this point. A warning from a third-party package is not ours to fix, so we add one ignore entry after error in the filter list, such as ignore::DeprecationWarning:yaml, and let the package author deal with it. And some changes never warn at all. The UTF-8 default in Python 3.15 is one: open() without an encoding argument writes text in the operating system’s encoding today and in UTF-8 from 3.15 on, which changes what a Windows machine writes. For this one the option -X warn_default_encoding, available since Python 3.10, reports every open() call that has no encoding argument:
with open("notes.txt", "w") as handle: # encoding_demo.py, no encoding argument
handle.write("creme brulee")
python3.12 -X warn_default_encoding encoding_demo.py # EncodingWarning: 'encoding' argument not specified
Passing the encoding we want makes the call behave the same on every version:
with open("notes.txt", "w", encoding="utf-8") as handle: # the fix, an explicit encoding
handle.write("creme brulee")
7. Step 4. Fix What the Warnings Name
This step ends with the step 3 command passing on Python 3.12, which means our own code is ready for the new version. We fix on 3.12, where everything else still works, one warning at a time. In Recipe Box, Python 3.13 removes cgi, Python 3.14 changes what the ast and asyncio calls do, and datetime.utcnow() keeps working on every version with only a warning, so it is the fix we do last.
| Helper | 3.12 | 3.13 | 3.14 | 3.15 |
|---|---|---|---|---|
| cgi.parse_header() | Works, warning | ModuleNotFoundError | ModuleNotFoundError | ModuleNotFoundError |
| visit_Num() | Works, warning | Works, warning | TypeError | TypeError |
| asyncio.get_event_loop() | Works, warning | Works, warning | RuntimeError | RuntimeError |
| datetime.utcnow() | Works, warning | Works, warning | Works, warning | Works, warning |

7.1. cgi.parse_header() to the email Package
The cgi module was deprecated in Python 3.11 and removed in 3.13, so import cgi raises ModuleNotFoundError there. The release notes point to the email package as the replacement for cgi.parse_header(), because both parse the same kind of header line. Here is the same lookup written against each:
import cgi
header = 'attachment; filename="pancakes.txt"'
value, params = cgi.parse_header(header)
filename = params.get("filename", "recipe.txt") # filename = "pancakes.txt", works up to Python 3.12
from email.message import Message
header = 'attachment; filename="pancakes.txt"'
message = Message()
message["Content-Disposition"] = header
filename = message.get_filename("recipe.txt") # filename = "pancakes.txt", works on Python 3.12 to 3.15
7.2. visit_Num() to visit_Constant()
The function ast.parse() turns text such as 1/2 * 3 into a tree of nodes, one per number and operator. Our QuantityEvaluator extends ast.NodeVisitor, a class that walks that tree and calls one method per node type, and it computes the value on the way. Python 3.14 removes ast.Num, so the visitor no longer calls a method named visit_Num(). The failure arrives in three steps, and only the third one is an error.
- The visitor finds no method for the number nodes and calls generic_visit() instead.
- The method generic_visit() returns None for each number.
- The expression 1/2 * 3 becomes None / None and raises TypeError.
import ast
class QuantityEvaluator(ast.NodeVisitor):
def visit_Num(self, node): # before, called up to Python 3.13
return node.n
class QuantityEvaluator(ast.NodeVisitor):
def visit_Constant(self, node): # after
return node.value
7.3. asyncio.get_event_loop() to asyncio.run()
On Python 3.14, asyncio.get_event_loop() raises RuntimeError when there is no running event loop, because it no longer creates one for us. For code that runs one coroutine from start to finish, the release notes recommend asyncio.run(), which is also two lines shorter:
import asyncio
from recipe_box.runner import load_recipe
loop = asyncio.get_event_loop() # before, a DeprecationWarning on 3.12, RuntimeError on 3.14
recipe = loop.run_until_complete(load_recipe("pancakes"))
recipe = asyncio.run(load_recipe("pancakes")) # after, recipe = {"name": "pancakes", "servings": 4}
7.4. datetime.utcnow() to datetime.now(UTC)
A call to datetime.utcnow() returns a naive value, meaning a datetime with no time zone attached, and it has warned since Python 3.12. The replacement datetime.now(UTC) returns a value that carries its zone. The output of strftime() is the same as before, but isoformat() now ends with the offset +00:00, so any code that parses the stored text has to accept that offset:
from datetime import UTC, datetime
stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M") # stamp = "2026-10-17 10:20", same format as utcnow()
iso = datetime.now(UTC).isoformat() # iso = "2026-10-17T10:20:58.790753+00:00", utcnow() has no +00:00
With the four fixes in, the step 3 command passes on 3.12. The same command in the 3.15 environment still fails, and the next step says why.
8. Step 5. Check the Dependencies
This step ends with a requirements.txt that installs on Python 3.15, or with the name of the package that stops it. Our code is ready, but every third-party package has to support the new version as well.
A wheel is a ready-made package file built for one Python version. Its file name says which one; cp315 in the name means CPython 3.15, and CPython is the standard Python from python.org, the one you are almost certainly running. When a package has no wheel for our version, pip downloads the source code and tries to compile it on our machine. That is slow, it needs a C compiler for some packages, and it fails outright when the package code does not support the new version.
So the check is one install into the 3.15 environment with source builds refused, through the pip option –only-binary=:all:. A missing wheel then shows up as an error instead of a slow compile:
.venv315/bin/pip install --only-binary=:all: -r requirements.txt # pip
uv pip install --only-binary :all: -r requirements.txt # uv, same check
The before requirements fail, because Pillow 10.4.0 has wheels only up to Python 3.13. This is uv’s message; pip stops with the same cause:
error: No solution found when resolving dependencies
cause: Because pillow==10.4.0 has no usable wheels and you require pillow==10.4.0, we can conclude that your requirements are unsatisfiable.
You can also ask the question package by package, without touching any environment. pip download with –python-version 3.15 and –only-binary=:all: saves the wheel when one exists and errors when it does not, and it runs from any Python version. The Download files page of the package on PyPI lists the same wheel names.
# --no-deps checks this package only, -d wheels is the folder to save into
python3 -m pip download --only-binary=:all: --python-version 3.15 --no-deps -d wheels pillow==13.0.0
# Saved ./wheels/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
python3 -m pip download --only-binary=:all: --python-version 3.15 --no-deps -d wheels pyyaml==6.0.3
# ERROR: No matching distribution found for pyyaml==6.0.3
As of 2026-10-17, Pillow 13.0.0 has a wheel for Python 3.15 and PyYAML 6.0.3, the newest release, does not. So the after project raises the Pillow pin and builds PyYAML from source. PyYAML’s C part is optional and only makes it faster, so the build succeeds without a compiler and installs the pure Python version, in about 40 seconds on our build machine.
| Package | Pin in before | Wheel for Python 3.15 | Pin in after |
|---|---|---|---|
| Pillow | 10.4.0, wheels up to Python 3.13 | No | 13.0.0, wheel available |
| PyYAML | 6.0.1 | No | 6.0.3, no wheel yet, built from source |
Those are two of the three outcomes a missing wheel can have, and the package decides which one you get.
- Raise the pin, when a newer release has a wheel, as Pillow 13.0.0 does.
- Build from source, when the package has no wheel but its code runs on the new version, as PyYAML 6.0.3 does.
- Leave the service that needs the package on the old Python, when there is no newer release, no maintained fork and no alternative package.

After the install passes, pip check in the same environment reports any two packages whose requirements conflict, which a successful install does not always catch.
9. Step 6. Test on Both Versions
This step ends with a green run on both versions. The 3.12 run proves that the fixes from step 4 broke nothing, and the 3.15 run proves that the upgrade works. Running one test suite on several Python versions is called a version matrix, and the smallest matrix is the two environments from section 3.1, one command each:
.venv312/bin/python -W error -m unittest discover -s tests -t . # Ran 6 tests, OK
.venv315/bin/python -W error -m unittest discover -s tests -t . # Ran 6 tests, OK
If you have no CI (continuous integration, a server that runs the tests on every push), this is the whole step, and you can go on to section 10. The two subsections automate the same two commands.
9.1. On a Laptop With nox
The nox tool runs those commands for a list of versions from one file, noxfile.py in the project root, and creates one environment per version by itself. Install it with pip install nox into either environment, or run it as uvx nox. The line default_venv_backend = “uv” makes nox build the environments with uv, which also downloads any version that is missing; without uv, leave that line out and have each python3.x installed as in section 1.1. tox does the same job through a tox.ini file.
import nox
nox.options.default_venv_backend = "uv"
@nox.session(python=["3.12", "3.13", "3.14", "3.15"])
def tests(session):
session.install("-r", "requirements.txt")
session.run("python", "-W", "error", "-m", "unittest", "discover", "-s", "tests", "-t", ".")
Running nox goes through the four sessions and prints a summary. The after project passes on all four with warnings as errors:
nox > Ran 4 sessions in 6 seconds:
nox > * tests-3.12: success, took 2 seconds
nox > * tests-3.13: success, took a second
nox > * tests-3.14: success, took a second
nox > * tests-3.15: success
9.2. In CI With GitHub Actions
In GitHub Actions, the CI service built into GitHub, the matrix is a list of versions in the workflow file, the YAML file under .github/workflows/ that tells GitHub what to run on each push. The actions/setup-python step installs the version of the current matrix entry. Set fail-fast: false so that every version finishes; otherwise a failure on 3.15 hides the result on 3.12. The complete file is .github/workflows/tests.yml in the example folder, and this is the part that defines the matrix:
# excerpt of .github/workflows/tests.yml
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14", "3.15"]
10. Step 7. Switch the Runtime
This step ends with the app running on Python 3.15 in production, and the 3.12 environment still in place for a rollback. The work is to change every place that names the Python version. Which places exist depends on how the app is deployed, and a missed one leaves part of the system on the old Python.
- For an app that runs from a virtual environment on a server, the environment itself. Create it again with the new interpreter, install the dependencies, and restart the service that uses it; the three lines of section 3.1 are the whole change.
- The base image in the Dockerfile, if the app runs in Docker.
- The image of the CI service.
- The runtime setting of the hosting platform, if a cloud service picks the Python for you.
- The version setting of the linters and type checkers, such as target-version in the ruff settings of section 11.
- The key requires-python in pyproject.toml, which says which Python versions the project itself supports.
Going back is the same change in reverse, the old environment or the old image deployed again. Keep 3.12 in the step 6 matrix until you no longer need that option.
For Docker, the change is the first line of the Dockerfile:
FROM python:3.15-slim
For requires-python, the value is the oldest version still in the matrix. Once nothing runs on 3.12 anymore and it leaves the matrix, 3.13 is the oldest version left:
# pyproject.toml, after Python 3.12 is removed from the matrix
requires-python = ">=3.13"
Projects that manage their environment with a tool other than venv create the new one with that tool. Each command makes an empty environment on Python 3.15, and the dependencies are installed into it afterwards.
| Tool | Create the environment on Python 3.15 |
|---|---|
| venv | python3.15 -m venv .venv |
| uv | uv venv –python 3.15 .venv |
| Poetry | poetry env use 3.15 |
11. Optional. Modernize the Syntax With ruff
This section produces code written in the syntax of the newer version, and nothing in the seven steps depends on it. Some code is old syntax that still runs, such as typing.List where plain list has worked since Python 3.9. A linter is a program that reads code and reports patterns, and some linters rewrite the pattern too. Two of them do this job: pyupgrade rewrites files in place behind a flag such as –py312-plus, and the ruff linter makes the same rewrites through its UP rules (UP for upgrade, the rule family it took over from pyupgrade). We use ruff here, because its DTZ rules also report datetime.utcnow() and its F rules report plain errors such as unused imports, so one run covers all three. Both install with pip:
.venv312/bin/pip install ruff # or run it as: uvx ruff ...
Set target-version to the oldest Python still in the matrix, 3.12 here. Then ruff only writes syntax that 3.12 understands, and the code still runs if you have to go back. The settings go in pyproject.toml, or in a file named ruff.toml for a project without one:
# pyproject.toml
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = ["UP", "DTZ", "F"]
# ruff.toml, for a project without pyproject.toml
target-version = "py312"
[lint]
select = ["UP", "DTZ", "F"]
Running ruff check . –output-format concise lists nine findings in the before project. A [*] after a code means ruff can rewrite that finding itself:
recipe_box/clock.py:6:12: DTZ003 `datetime.datetime.utcnow()` used
recipe_box/models.py:1:1: UP035 `typing.Dict` is deprecated, use `dict` instead
recipe_box/models.py:1:1: UP035 `typing.List` is deprecated, use `list` instead
recipe_box/models.py:4:26: UP006 [*] Use `list` instead of `List` for type annotation
recipe_box/models.py:4:31: UP006 [*] Use `dict` instead of `Dict` for type annotation
recipe_box/models.py:4:62: UP045 [*] Use `X | None` for type annotations
recipe_box/models.py:4:71: UP006 [*] Use `dict` instead of `Dict` for type annotation
recipe_box/models.py:11:19: UP006 [*] Use `dict` instead of `Dict` for type annotation
recipe_box/models.py:12:12: UP031 Use format specifiers instead of percent format
Found 9 errors.
Adding –fix –unsafe-fixes applies the rewrites. An unsafe fix is one that can change behavior, such as turning percent formatting into str.format(), so ruff holds it back until you ask. This is what the rewrite does to one function:
# before
def find_recipe(recipes: List[Dict[str, str]], name: str) -> Optional[Dict[str, str]]:
return None
# after
def find_recipe(recipes: list[dict[str, str]], name: str) -> dict[str, str] | None:
return None
The one finding ruff leaves is utcnow(), because its replacement changes the value (section 7.4) and that is our decision. What neither ruff nor pyupgrade finds is the removed cgi module or the changed asyncio behavior, because neither tool tracks what a Python version removed. The table shows the gap each check leaves, and why the seven steps use all of them.
| Check | Finds | Misses |
|---|---|---|
| python -W error on the old version (step 3) | Deprecated calls that the tests execute | Code that no test runs |
| ruff UP and DTZ rules | Old syntax such as List[str], and datetime.utcnow() | Removed modules such as cgi, and changed behavior |
| pyupgrade | Old syntax such as List[str], with a rewrite | Removed modules such as cgi, and changed behavior |
| Tests on the new version (step 6) | Removed modules and changed behavior | Code that no test runs |
12. Python Upgrade FAQs
12.1. How Do I Upgrade the Python Version of an Existing Project?
Follow the seven steps in section 3. In short: install the new Python next to the old one, create a virtual environment on it, install the dependencies into it, and run the tests there with -W error. The old environment stays untouched until the new one passes.
12.2. Is It Safe to Upgrade Python?
A patch upgrade such as 3.14.6 to 3.14.8 is safe for almost every project, because it brings bug fixes and security fixes only. A minor upgrade loses most of its risk once the tests pass with warnings as errors on the old version, because the deprecation policy gives every removal at least two years of warnings first. Three risks remain: the changes that never warn (the start method and the default encoding), code that no test runs, and dependencies without wheels.
12.3. Which Python Version Should I Use in 2026?
Python 3.14 or 3.15, because both still get bugfix releases. The newest is 3.15.0, and 3.14 is the better pick when a dependency has no wheel for 3.15 yet. Anything still on 3.10 is past end of life and gets no security fixes.
12.4. How Often Should I Upgrade Python?
Once a year is enough. Python ships a new minor version every October and supports it for five years, so a project that moves one version a year stays supported and only ever reads one set of release notes.
12.5. Do I Have to Reinstall Packages After Upgrading Python?
Yes. A virtual environment belongs to one Python version, and compiled packages are built for one interpreter tag such as cp315. Create a new environment for the new version and install the dependencies again. Copying the old site-packages folder does not work, because the compiled packages in it were built for the old interpreter.
12.6. Do I Need pyproject.toml to Follow This Guide?
No. A project with only requirements.txt can do every step. The pytest and ruff settings go in pytest.ini and ruff.toml instead (section 6 and section 11 show both forms), and requires-python only matters when the project is published as a package.
12.7. Can Two Python Versions Be Installed on the Same Machine?
Yes. Each version has its own interpreter, and installing one does not touch the other (section 1.1). On Windows, py -0 lists the installed versions. With uv, uv python list shows what is installed and uv venv –python 3.14 picks one for a project.
13. Conclusion
A Python upgrade is safe when the warnings are fixed before the interpreter changes. In Recipe Box, one run of the tests with -W error on Python 3.12 named all four problems while the project still worked, and the fixes then passed on every version from 3.12 to 3.15.
The release notes give the removals and their replacements. The wheels decide whether the dependencies can follow. Two virtual environments, one per version, are enough to test both; nox and CI only automate that. One thing to carry away: only the tests on the new version catch the changes that never warn, such as the start method and the default encoding, so run them even when the old version is clean.
14. References
- What’s New in Python 3.15
- What’s New in Python 3.14
- What’s New in Python 3.13
- What’s New in Python 3.12
- What’s New in Python 3.11
- Status of Python versions
- PEP 387, Backwards Compatibility Policy
- PEP 594, Removing dead batteries from the standard library
- PEP 686, UTF-8 mode as default
- Python warnings module
- venv, creation of virtual environments
- pytest, how to capture warnings
- uv documentation, Python versions
- ruff rules, pyupgrade (UP)
- nox documentation
Drop me your questions related to upgrading the Python version in the comments.
Happy Learning !!