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.1and2.1.0are the same release under zero-padding, so>=2.1holds, and the2.*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.*.