You write except ValueError, TypeError: on your laptop, which runs Python
3.14, and your tests pass. CI runs Python 3.12, and the module fails to
import:
SyntaxError: multiple exception types must be parenthesized
Code can break on another Python version in three ways. Each one needs a different fix.
New syntax needs a parser that knows it
Python parses a whole file before it runs any of it. Parsing means reading the code and checking its grammar. So a version check cannot protect new syntax:
import sys
if sys.version_info >= (3, 14):
def to_int(text: str) -> int:
try:
return int(text)
except ValueError, TypeError:
return 0
Python 3.12 and 3.13 reject this file with the same SyntaxError, before the
if runs. The same goes for t-strings and the other new syntax in this track.
You have three choices. Write syntax that your oldest version understands,
raise your minimum version, or move the new code into a module that older
versions never import. Here the first choice is easy, because
except (ValueError, TypeError): works on every version.
Import new library names conditionally
A new name in the standard library is different. The import statement is
old syntax, so a version check works. TypeIs arrived in typing in Python
3.13, and the typing_extensions package provides it for older versions:
import sys
if sys.version_info >= (3, 13):
from typing import TypeIs
else:
from typing_extensions import TypeIs
def is_text(value: object) -> TypeIs[str]:
return isinstance(value, str)
List typing_extensions in your project's dependencies, because Python does
not ship it. mypy evaluates the version check against the version you
target, and checks only the matching branch. If you set
python_version = "3.12", it reports a plain from typing import TypeIs as
an error, because 3.12's typing has no TypeIs.
Changed behavior shows up only when the code runs
The hardest case is code that parses on every version but behaves differently when it runs. Two recent changes cause real bugs.
Annotations that name their own class. Python 3.14 no longer evaluates annotations when it defines a function. It waits until something reads them. So this class works on 3.14 and fails on 3.12 and 3.13:
class Node:
def add(self, child: Node) -> None:
...
On 3.12 and 3.13, Python evaluates child: Node while it is still building
the class. The name Node does not exist yet, so Python raises NameError.
mypy accepts this code even with python_version = "3.12", so only running it
on 3.12 finds the bug. Quote the name, as in child: "Node", and the class
works on every version.
The default text encoding. open(path) and Path.read_text() without
encoding= use a default that depends on the version. Python 3.15 uses UTF-8.
Python 3.12 to 3.14 use the operating system's locale, which is often
cp1252 on Windows. A UTF-8 file with an accented character can then raise
UnicodeDecodeError, or come back as garbled text. Pass the encoding every
time, as you did with bytes in the
decoding exercise earlier in this
track:
from pathlib import Path
def read_config(path: Path) -> str:
return path.read_text(encoding="utf-8")
Promise a range of versions, then test it
Write your oldest supported version in three places, so every tool agrees:
[project]
requires-python = ">=3.12"
[tool.mypy]
python_version = "3.12"
[tool.ruff]
target-version = "py312"
requires-python stops pip from installing your package on an older Python.
mypy checks your code against the 3.12 standard library. Ruff stops
suggesting rewrites that need a newer version.
None of these settings runs your code. Run your tests on the oldest and the newest version you support, for example in a CI matrix, which runs the same job once for each version. With uv, you can do it locally:
uv run --python 3.12 pytest
uv run --python 3.15 pytest
Check your understanding
Your package declares requires-python = ">=3.12", and mypy targets 3.12. Do you know that the package works on 3.12?
Show answer
Hide answer
No. Both settings state a promise, and neither runs the code. mypy accepts the
unquoted Node annotation for a 3.12 target, and the class still raises
NameError on 3.12. Only running the tests on 3.12 shows that.
Going further
The version status table shows which
Python versions still get bug fixes and security fixes. Python 3.15's UTF-8
default comes from PEP 686. Anyone who runs your code can turn it off with
-X utf8=0, so passing encoding= is still the safe choice. Python 3.14's lazy annotations come from
PEP 649. If your code reads annotations at runtime, read about the new
annotationlib module first.