Skip to content
← All articles

Version budgeting: what you may actually use

Getting this wrong in an application means a broken image. In a library it means breaking every downstream user on an older interpreter, with an import-time crash rather than graceful degradation.

Every new Python release adds things you want. Every one of them is a decision about who can run your code.

The decision procedure is short, and the mistake people make is not choosing badly — it is never choosing at all, so the answer becomes “whatever the newest thing I read about last week was”, discovered by someone else in a bug report.

Three tiers

Split your available features into three buckets, and know which bucket everything you are about to type belongs in.

Baseline — free. Whatever your requires-python floor is. As of this course, 3.12: PEP 695 generics (class Box[T]:), the type statement, @override, itertools.batched, Buffer, the dataclasses.asdict fast path. Use these without a second thought.

Above the floor, with a fallback. Typing features are the good case here, because typing_extensions backports them: TypeIs and ReadOnly (3.13), PEP 696 type-parameter defaults (3.13), TypeForm and closed TypedDict (3.15). Import from typing_extensions and your 3.12 users are fine.

Above the floor, no fallback. Runtime features and syntax. copy.replace, Queue.shutdown and @deprecated (3.13); concurrent.interpreters, InterpreterPoolExecutor and t-strings (3.14); lazy import, frozendict and comprehension unpacking (3.15). For these the choice is: raise your floor, or do not use them.

The rule that has no workaround

Syntax cannot be feature-detected.

A runtime feature can be probed. If copy.replace might not exist, you can write:

try:
    from copy import replace
except ImportError:
    def replace(obj, /, **changes): ...

That works because the file parses on every version; only the import fails.

Syntax does not work that way. A module containing 3.15’s lazy import json fails to compile on 3.12. Not at the line — the whole module, before a single statement executes. There is nothing to catch, because your try was never compiled either.

# This does not do what its author hoped.
try:
    exec("lazy import json")
except SyntaxError:
    import json

This is a strategy for a module that mentions the syntax in a string, and no strategy at all for a module that contains it. If you need new syntax on an old interpreter, the only real answers are: raise your floor, or isolate the new-syntax code in a separate module that is imported conditionally — which is a lot of machinery, and usually the wrong trade.

💡Your library supports 3.12+. A contributor's PR uses type Alias = int | str and except* ValueError:. Which is safe? click to reveal

Both, and the reasoning differs.

type Alias = int | str is the PEP 695 type statement, new syntax in 3.12 — which is your floor, so it is baseline and free.

except* for exception groups is 3.11 syntax, comfortably below your floor.

The general procedure is what matters: for each new construct, find the version it was added to the language, and compare it to your requires-python floor — not to the interpreter you happen to be running. The failure mode is a contributor on 3.14 writing something that parses fine for them and fails to import for a 3.12 user, with no test catching it because CI’s matrix does not include the floor.

Which is the setting the next section is about.

The setting almost everybody gets wrong

[tool.mypy]
python_version = "3.12"

python_version should be your lowest supported version, not your development interpreter. If requires-python = ">=3.12" and you develop on 3.14, then a mypy configured for 3.14 will happily accept a stdlib function that did not exist in 3.12, an argument added in 3.13, and a typing name introduced in 3.14. Your gate is validating a claim you are not making.

Set it to the floor and the checker becomes an enforcement mechanism for requires-python rather than a description of your laptop.

The same applies to ruff’s target-version, which controls which modernisation rewrites UP applies. Set too high, ruff will rewrite your code into syntax your oldest supported interpreter cannot parse — an autofix that breaks your users.

Applications and libraries budget differently

An application controls its runtime. Its floor is whatever its base image ships, and raising it is a Dockerfile edit plus a test run. Being on an old version is a choice you can revisit any Tuesday, so there is little reason to hold a floor you have outgrown.

A library cannot see its users. Raising the floor in a minor release breaks every downstream project pinned below it — and breaks it at import time with a SyntaxError, which is the worst possible failure shape: not a clear “requires Python 3.14” message, but a traceback pointing at a line inside your package.

The mitigation is requires-python, and it is why the field matters more than its one-line appearance suggests. With it set correctly, an installer on 3.12 refuses the new release and resolves to the last version that supported 3.12 instead. The user gets working software and a resolution message rather than a crash. Without it, they get the crash.

💡You want to drop 3.12 support in your library. What is the release procedure that does not break anybody? click to reveal

Four steps, and the order matters.

Bump the minor or major version, never a patch. Dropping a supported interpreter is a breaking change for the people using it, whatever the code did or did not do.

Raise requires-python to >=3.13 in the same commit as the first use of 3.13-only syntax. Not before — that strands users early for no benefit — and never after, because a release with 3.13 syntax and a 3.12 floor is the crash-at-import scenario.

Make sure the last 3.12-supporting release is on the index and correct. This is what requires-python resolves back to, and it is the thing actually protecting your users. A release history where the previous version was broken means the fallback is broken too.

Announce it in the changelog with the version number, so somebody debugging a resolution that pinned them to an older release can find out why in one search.

Done this way, a 3.12 user runs pip install --upgrade yourlib, gets the last compatible release, and sees a message explaining the constraint. Done the other way, they get a SyntaxError from inside your package and open an issue.

Seams, Modules, Packaging and Tooling · step 36 of 36

That's the end of this track. Review it or pick another.

← Back to Seams, Modules, Packaging and Tooling