Project Setup

Pinning the Python Version for a CLI

Decide which Python versions a CLI supports - requires-python, .python-version, a CI matrix that matches, and when to drop an old interpreter.

Updated

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-python in pyproject.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-version sets 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

How requires-python protects users The requires-python field is read by the installer, which refuses to install on an unsupported interpreter instead of failing later at import time. How requires-python protects users requires-python declared in metadata Installer checks it before downloading Clear refusal or a clean install published result Without it, an unsupported interpreter installs successfully and fails with a syntax error.
[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

Where the interpreter version is decided A table of places a Python version is specified for a command line project: project metadata, the lockfile, CI matrix and tool install flags. Where the interpreter version is decided Place Controls Who reads it requires-python which versions may install pip, uv, pipx .python-version the local default uv, pyenv CI matrix what is actually tested your workflow pipx --python the installed tool's interpreter the user The first and third must agree, or you are testing versions you do not support and shipping ones you do not test.

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

Deciding when to drop an old Python A support timeline: a Python release is supported for five years, and a tool typically drops it once upstream security support ends. Deciding when to drop an old Python New minor adopt at leisure release Widely available safe to require +1 yr End of life upstream security support ends +5 yr Drop it raise requires-python, bump major after dropping a version is a breaking change for anyone still on it Following upstream end-of-life dates gives you a defensible policy nobody has to argue about.

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:

  1. Announce it in the release notes of a minor version, with the version and timeframe.
  2. 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.
  3. Raise requires-python in 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.