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.