Skip to content
← All articles

Version specifiers and the upper-bound argument

One over-tight upper bound in a widely-used library strands thousands of downstream projects with no override. An unbounded >= in an application with no lockfile means the deploy pulls a different tree every time.

Two failures, opposite in shape, both caused by a single line of metadata.

The library that capped too early. A popular package ships dependencies = ["attrs>=24.1,<25"]. attrs 25 comes out. Now every project that depends on both your library and anything requiring attrs 25 gets a resolution failure — and there is no override. A consumer cannot loosen your bound; they can only pin your library to an older release, fork it, or drop one of the two dependencies. Your guess about a future release became everybody else’s build error.

The application that capped nothing. dependencies = ["httpx>=0.27"] and no lockfile. Today’s deploy resolves to 0.27.4; next Tuesday’s resolves to 0.28.0, which changed a default, and the artefact you tested is not the artefact you shipped. Nothing in the repository changed. The build is simply not reproducible.

These are not the same mistake, and — this is the part people miss — they do not have the same fix.

Libraries and applications answer differently

An application owns a lockfile. It resolves once, records the exact resolution, and installs from that record. Because the lockfile pins everything exactly, the ranges in pyproject.toml can be generous: they express compatibility, not the deployed truth. Upper bounds buy you almost nothing here and cost you resolution failures during upgrades.

A library does not have a lockfile, and must not behave as though it does. Its ranges are the only thing consumers see, and every bound is a constraint imposed on every downstream project forever. The default should be a lower bound and nothing else. Add an upper bound when you have evidence — a known incompatibility, a dependency with a documented history of breaking changes in minor releases — and not as a precaution against a release that does not exist yet.

💡Your library uses one function from a dependency, and that dependency has broken its API in every minor release for three years. Cap it or not? click to reveal

Cap it — this is the case upper bounds exist for. You have evidence, not a hunch, and the cost of being wrong is bounded: consumers get a resolution error that names your package, and you can widen it in a patch release.

Two things make it much less painful.

Cap at the next major if the project follows semantic versioning, and at the next minor only if it demonstrably does not. <3 is far cheaper for the ecosystem than <2.5.

And write down why, next to the bound, in the commit message or a comment. The reason an upper bound is so often wrong in practice is that nobody remembers whether it encoded a real incompatibility or somebody’s caution from 2021, so it gets carried forward forever.

The general principle: an upper bound is a claim about the future. Make it only where you have evidence, and make it as loose as the evidence allows.

requires-python, and why <4 is harmful

requires-python is a hard gate: an installer refuses to install the distribution on an out-of-range interpreter, and will fall back to an older release of your package if one is compatible. That fallback is the useful behaviour and the reason the field matters — it is what stops a 3.9 user installing a wheel full of match statements and getting a SyntaxError instead of a resolution message.

requires-python = ">=3.12,<4" is widely considered harmful. The upper bound does no work: Python 4 does not exist and there is no reason to believe it will be more breaking than 3.12 → 3.13 was. What it does do is guarantee that on the day Python 4 ships, every package with that line is uninstallable until each maintainer cuts a release — including the abandoned ones. Write >=3.12.

The operators

==  != >= <= > <      exact and ordered comparison
~=                    compatible release
,                     conjunction: every clause must hold

Two subtleties are worth knowing precisely, and they are what the problem below is about.

Version comparison is numeric, segment by segment — not lexicographic. 1.10 > 1.9 is true. String comparison says the opposite, and code that gets this wrong appears to work for the first nine releases.

Missing segments are zero. 1.0 and 1.0.0 are the same release. So ==1.0 matches 1.0.0, and >=1.2 matches 1.2.0.

~= is a shorthand for a pair of clauses, and the number of segments you write changes what it means:

Written Means
~=1.4.2 >=1.4.2, ==1.4.* — patch releases of 1.4
~=1.4 >=1.4, ==1.* — any 1.x from 1.4

So ~=1.4.2 accepts 1.4.9 and rejects 1.5.0, while ~=1.4 accepts both. The dropped segment is the one allowed to move. This is the operator people reach for when they mean “compatible” and then get surprised by, because the meaning depends on how precisely they happened to write the version.

💡A pin reads ~=2.1. Which of 2.1.0, 2.4.7, 3.0.0 and 2.0.9 does it accept? click to reveal

~=2.1 expands to >=2.1, ==2.*.

  • 2.1.0 — accepted. 2.1 and 2.1.0 are the same release under zero-padding, so >=2.1 holds, and the 2.* prefix matches.
  • 2.4.7 — accepted. Greater than 2.1, still in the 2 series.
  • 3.0.0 — rejected. Greater than 2.1, but ==2.* fails.
  • 2.0.9 — rejected. Inside 2.*, but less than 2.1.

The two clauses are doing different jobs: the >= sets the floor, the prefix match sets the ceiling. Reading ~= as “any newer 2.x” is right here and wrong for ~=2.1.3, where the ceiling is 2.1.*.