Skip to content
← Back to explainers

Python explainer

Write Code That Runs on Several Python Versions

About 4 min to read. Allow extra time to work through examples.

New syntax, new library names and changed behavior each break code on older Python versions in a different way. Learn the fix for each, and how to test every version you promise to support.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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.

step 23 of 23

Pattern Matching, f-Strings and Newer Syntax

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks