A CLI runs on whatever interpreter the user has, which is rarely the one you developed against. Declaring which versions you support — and testing exactly those — is what turns "it should work" into something an installer can enforce before anything breaks.
TL;DR
- Declare
requires-pythoninpyproject.toml; installers refuse unsupported versions up front. - Test the ends of that range in CI, not the middle — those are the versions that break.
.python-versionsets the local default; it is not a support statement.- Raising the floor is a breaking change: bump the major version and say so.
- Follow upstream end-of-life dates for a policy nobody has to argue about.
Prerequisites
A packaged CLI with a pyproject.toml, and a CI workflow you can add a matrix to.
What requires-python actually does
[project]
name = "mytool"
requires-python = ">=3.11"
That line is metadata the installer reads before downloading anything. On an unsupported
interpreter, pip install mytool or pipx install mytool fails with a message naming the required
range — instead of installing successfully and failing later with a SyntaxError on a match
statement, which is the failure people actually report.
Two details are worth getting right. Use >= with no upper bound unless you have a specific reason:
capping at <4.0 is conventional and harmless, but <3.14 means your tool stops installing the day
a new Python is released, which is rarely what you want and always discovered at the worst time.
And keep it honest. >=3.9 while your code uses X | None syntax means the metadata promises
something the code cannot deliver, and the failure moves back to being a confusing runtime error.
The four places a version is decided
They serve different purposes and are easy to conflate:
requires-python = ">=3.11" # what may install this package — a promise to users
echo "3.12" > .python-version # what uv or pyenv uses locally — a developer convenience
matrix:
python: ["3.11", "3.13"] # what is actually verified — the truth
pipx install mytool --python 3.12 # what the user's install runs on — their choice
The pair that must agree is the first and third. If requires-python says >=3.10 and CI tests
only 3.12, you are shipping support you have never verified; if CI tests 3.9 and the metadata says
>=3.11, you are testing something nobody can install.
Choosing the floor
Two considerations, pulling in opposite directions.
Language features you want. Each release brings things worth having: tomllib in 3.11 removes a
dependency for TOML configuration, and structural pattern matching, X | None annotations and
ExceptionGroup all arrived recently enough to matter. Raising the floor makes code simpler.
Where your users are. Distribution Pythons lag. A tool aimed at developers can require a recent version; one aimed at servers or CI images has to survive whatever the base image ships.
The defensible policy is to follow upstream: Python releases get five years of support, and dropping a version once its security support ends is a rule nobody has to argue about. In practice that keeps you supporting three or four versions at a time, which is a manageable matrix.
For a new CLI in 2026, >=3.11 is a reasonable default: it gets tomllib, the improved error
messages, and ExceptionGroup, while remaining widely available.
A CI matrix that matches
strategy:
fail-fast: false
matrix:
python: ["3.11", "3.13"] # the ends of requires-python
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen --python ${{ matrix.python }}
- run: uv run pytest -q
Testing the ends rather than every version is a deliberate economy. The oldest supported version catches syntax and library features you accidentally relied on; the newest catches deprecations and behaviour changes. Versions in between rarely surface anything distinct, and each one is CI minutes spent for very little.
What genuinely justifies more entries is the platform axis. Windows path handling, console
encoding and the bin versus Scripts split break tools that Linux CI declares healthy.
Add one job that verifies the metadata is enforceable:
refuses-old-python:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with: { python-version: "3.10" }
- run: pip install dist/*.whl && exit 1 || echo "correctly refused"
Raising the floor later
Dropping a Python version is a breaking change for anyone still on it: their next pipx upgrade
either refuses or, worse, keeps them silently on an old release.
The sequence that treats users well:
- Announce it in the release notes of a minor version, with the version and timeframe.
- Warn at run time for a release or two when running on the interpreter about to be dropped — one line on stderr, naming the version and the deadline.
- Raise
requires-pythonin a major release, with a changelog entry.
That last step matters: a user on the old interpreter will keep resolving to your last compatible version, which is exactly the behaviour you want as long as that version was a real release rather than an accident.
if sys.version_info < (3, 12):
err.print("[yellow]mytool 3.0 will require Python 3.12; you are on "
f"{platform.python_version()}[/]")
UX considerations
Say the requirement in the README, next to the install line. A user hitting a resolver error is looking for that sentence, and finding it in the metadata means reading a traceback first.
Report the interpreter in --version. Half of all "it does not work" reports resolve to the
wrong Python, and printing the path answers it immediately — see
exposing version info.
Do not pin an exact version for users. requires-python = "==3.12.*" makes your tool
uninstallable next to anything with a different constraint. Ranges, always.
Testing the behaviour
Beyond the matrix, one test keeps the declaration and the code honest:
import sys
import tomllib
from pathlib import Path
def test_requires_python_matches_the_ci_matrix():
project = tomllib.loads(Path("pyproject.toml").read_text())
declared = project["project"]["requires-python"]
workflow = Path(".github/workflows/test.yml").read_text()
assert declared == ">=3.11"
assert '"3.11"' in workflow # the floor is actually tested
It looks pedantic and it catches the common drift: someone raises the floor in pyproject.toml
without touching the matrix, and the oldest supported version silently stops being verified.
Running your test suite on the floor version locally is worth doing before a release:
uv run --python 3.11 pytest.
Conclusion
Declare the range, test its ends, keep the local default separate from the support statement, and treat raising the floor as the breaking change it is. Following upstream end-of-life dates turns what is otherwise a recurring debate into a rule you can point at.
Frequently asked questions
Should I support the newest Python immediately?
Support it as soon as your dependencies do, which is usually a few weeks after release. Adding it to the matrix early is cheap and catches deprecations before users do; requiring it is a different question and usually premature.
What is .python-version for, then?
A local convenience: uv and pyenv read it to pick an interpreter for the project. It says nothing about what you support, and it should generally name a version in the middle of your range rather than the floor — you want the fast path locally and the boundaries in CI.
How many versions should the matrix cover?
Two Python versions and two platforms is a good default: the ends of your range on Linux and Windows. Add macOS if you ship binaries or touch filesystem semantics; add intermediate Pythons only when a specific bug justifies it.
Does requires-python affect an already-installed tool?
No. It is consulted at install time, so a tool installed before you raised the floor keeps working. That is why the run-time warning is worth adding — it is the only way an existing user learns about the upcoming change.
What if a dependency drops a version before I do?
Then your effective floor has moved whether you like it or not: the resolver will refuse to install
your tool with that dependency on the old interpreter. Raise requires-python to match, and treat
it as the breaking change it is — pinning the dependency to an old version instead is a short-term
answer with a security cost.
Can I support a version my CI cannot install?
You can declare it, and you should not. Support you cannot verify is a guess, and the first bug report from that version arrives with no way to reproduce it. If a version is genuinely hard to run in CI — an old distribution Python, say — either find a container that provides it or narrow the declared range to match what you test.