skip to content

Declaring a Version Floor

Picking the oldest interpreter you promise to support, declaring it so installers hand older users an older release, and weighing what each newer release buys you. A standard senior probe.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What does requires-python in pyproject.toml do when an old interpreter installs your package?

level: middleimportance: must knowfreq 55%

answer

  1. A promise published as metadata
  2. The installer reads it before downloading
  3. Old users are downgraded, not refused
  4. It becomes Requires-Python in the built metadata
  5. Only works if earlier releases declared it too

basics

~20 s

It declares the interpreter range the distribution supports, and lands in the built metadata as Requires-Python. An installer filters out releases whose range excludes the running interpreter and resolves to the newest release that still allows it.

solid answer

~50 s

`requires-python` sits in the `[project]` table of `pyproject.toml` and holds a version specifier such as `">=3.11"`. The build backend copies it into the sdist's and wheel's core metadata as the `Requires-Python` field, and a package index republishes it per file, so an installer can rule out candidates without downloading them. The effect for a user below the floor is not an error but a **downgrade**: pip skips every release that excludes their interpreter and installs the newest one that allows it, printing a note that it ignored the newer versions. That is why the field is what makes raising your floor safe — old users keep getting your last compatible release. It only works if the floor was declared on the earlier releases too: a release published with no `Requires-Python` is treated as compatible with everything.

code

python · 6 lines
python
from importlib.metadata import distributions

for dist in distributions():
    floor = dist.metadata["Requires-Python"]
    if floor:
        print(dist.metadata["Name"], "->", floor)

go deeper

for a junior

Know where the field lives (the [project] table of pyproject.toml), that its value is a version specifier such as >=3.11, and that installers rather than the interpreter enforce it.

for a middle

Explain the whole path: pyproject field, Requires-Python in the built metadata, data-requires-python on the index page, and the resolver walking back to the newest release that still allows the user's interpreter.

for a senior

Show that you have operated it: declare it from the first release, never cap a library's upper bound, keep it at or above your dependencies' floors, and add a runtime check when installing below the floor would fail obscurely.

for a principal

Own it as a compatibility contract. Decide what the floor promises, how it is communicated in release notes, and whether the previous line still gets fixes once the floor moves past it.

### Where the field lives and what it becomes In a PEP 621 `pyproject.toml`, the floor is one line: ```toml [project] name = "thumbnailer" version = "2.0.0" requires-python = ">=3.11" ``` The value is a PEP 440 version specifier set, the same grammar used for dependency versions, so `>=3.11`, `>=3.10,!=3.11.0`, and `~=3.12` are all legal. The build backend copies it verbatim into the core metadata of both the sdist and every wheel, under the field name `Requires-Python`. The legacy spelling in a setuptools `setup.py` or `setup.cfg` is `python_requires`; it produces exactly the same metadata field, and mixing the two spellings in one project is a common source of "my floor is not being respected" confusion. ### How an installer uses it A package index that follows PEP 503 publishes the value alongside each file link as a `data-requires-python` attribute, which means an installer can evaluate compatibility from the index page alone, without downloading or building anything. pip has honoured this since 2016. The resolution consequence is the important half, and it is the part candidates usually get wrong. Suppose your project shipped 1.4.0 with `requires-python = ">=3.9"` and then 2.0.0 with `>=3.11`. A user on 3.9 who runs `pip install thumbnailer` does **not** get an error and does **not** get a broken 2.0.0. pip discards 2.0.0 as an incompatible candidate, keeps walking back through the release history, and installs 1.4.0, printing something like: ```console Ignored the following versions that require a different python version: 2.0.0 Requires-Python >=3.11 ``` Only if *no* release in the history allows the running interpreter does the install fail outright. This is the mechanism that makes raising a floor a non-event for the people you are leaving behind: they keep resolving to the last release that promised to support them. ### The trap: the field has to have been there all along A release published without `Requires-Python` carries no constraint, so every installer considers it compatible with every interpreter. If your 1.x line never declared a floor and your 2.0 declares `>=3.11`, then a 3.9 user still gets a working install of 1.4.0 — fine. But the reverse case bites: if you *only* start declaring the floor when you raise it, users on future-incompatible interpreters can still be handed old releases that never worked for them. You cannot retroactively edit the metadata of an uploaded artefact; the only levers after the fact are yanking the release or shipping a new patch release of the old line with the corrected floor. Declare the field from your very first release. ### What it does not do `requires-python` is an *install-time* constraint enforced by the installer, not by the interpreter. Nothing checks it at import time. `pip install --ignore-requires-python` overrides it, installing from a local wheel or an unzipped directory bypasses it entirely, and a vendored artefact copied into place is never consulted. If running on an unsupported interpreter would fail confusingly rather than obviously, add a `sys.version_info` check that raises a clear error at import. It also is not a statement about your dependencies. It constrains only the interpreter. If a library you depend on has a higher floor than yours, your own metadata will not warn anyone; the resolver simply fails to find a compatible version of that dependency, with an error naming the dependency rather than you. Keeping your floor at or above the highest floor in your dependency tree is a maintenance chore the field does not do for you. ### Upper bounds An upper cap — `requires-python = ">=3.11,<3.15"` — is legal and almost always a mistake for a library. The day a new interpreter is released, every user on it is pushed back to an older release of yours, or gets a resolution failure, until you notice and cut a release that widens the cap. Because your metadata cannot be changed after upload, the fix always requires a new release, and in the meantime the failure looks like your package being abandoned. Applications and internal services can reasonably cap, because their environments are chosen deliberately; a widely-depended-on library should state a floor and let the ceiling be discovered by testing. ### The one-line summary an interviewer wants It is a promise, published as metadata, that installers read *before* download to serve each user the newest release that still keeps that promise.

  • Your 2.0 raises the floor to 3.11. What does a user on 3.9 actually see when they install?
    A successful install of your newest release that still allows 3.9, plus a note listing the versions it ignored because they require a different Python. No error, no broken import. If nothing in your release history allows 3.9, only then does resolution fail, and the message is that no matching distribution was found.
  • Why is an upper bound such as ">=3.11,<3.15" usually wrong for a library?
    Because the cap fires the day the next interpreter ships, silently pushing every user on it back to an older release or failing to resolve, and metadata on an uploaded artefact cannot be edited, so the only fix is noticing and cutting a new release. Applications that control their own runtime can cap deliberately; a library should declare a floor and discover its ceiling by testing.
  • Does requires-python stop the package from running on an unsupported interpreter?
    No. It is enforced by the installer at resolution time only. A local wheel, an unzipped directory, a vendored copy, or pip's --ignore-requires-python all bypass it, and nothing consults it at import. If running below the floor would fail confusingly, add an explicit sys.version_info check that raises a clear error early.

It is a shop keeping the previous model on the shelf: new customers walk out with the new one, older customers still walk out with something that works for them.

saying these in an interview costs you the question

  • Thinking an old user gets an error rather than an older release
  • Believing it is enforced at import time
  • Assuming metadata on an uploaded release can be edited later
  • Capping the upper bound on a library by default
  • Confusing it with pinning dependency versions
  • Expecting it to account for the floors of your dependencies

context

open as a page

How do you use sys.version_info to guard code that only runs on newer Python?

level: juniorimportance: should knowfreq 45%

basics

~10 s

Compare sys.version_info against a plain tuple, as in sys.version_info >= (3, 12), and put the newer-only import or branch inside that guard. Never parse the sys.version string; tuple comparison is exact, cheap and unambiguous.

open as a page

When would you put a PEP 508 marker such as python_version < '3.11' on a dependency?

level: seniorimportance: should knowfreq 45%

basics

~20 s

When a requirement applies only to some environments — a backport needed below a given interpreter, or a helper needed only on one platform. The marker is evaluated by the installer in the target environment, so one artefact serves every environment.

open as a page

When is raising a library's requires-python floor justified, and what evidence decides it?

level: principalimportance: should knowfreq 35%

basics

~20 s

Raise it when the oldest supported interpreter costs more than it earns: upstream end-of-life passed, install share has collapsed, dependencies have already moved, and shims plus test-matrix cost are real. Evidence beats taste, and old users keep getting old releases.

open as a page