Version Pinning: Six Real Errors and a False pip Claim
TL;DR: I reproduced six real pip errors plus three constraint forms that fail silently. The worst one is not an error at all — pip's line Additionally, some packages in these conflicts have no matching distributions available for your environment is a false alarm. The package it names has versions in the index; I verified that with pip's own command and then traced the root cause into pip's source.
~=2.2lets 2.9 in but blocks 3.0.~=2.2.0locks the minor. One extra digit, completely different meaning.==1.0accepts1.0+downstream1, because PEP 440 says local labels are ignored entirely when the constraint names a public version.<1.7rejects1.7.0a1, but<1.7rc1accepts1.7a1. To allow pre-releases you must name a concrete rc.Specifier("~=1")raisesInvalidSpecifieroutright — single-segment versions are forbidden with~=.- The packaging docs site now documents the 26.x API. On the 25.0 installed here,
is_unsatisfiableand five sibling methods do not exist at all.
Everything below ran on Python 3.13.14 with packaging 25.0 and pip 26.1.2. The scripts are in the article so you can recompute each number.
The test environment, stated up front
This matters because every conclusion here is version-bound:
- Python 3.13.14
- Standalone
packagingversion 25.0 pipversion 26.1.2, whose vendoredpackagingis 26.2
Note that last line: two copies of packaging exist side by side on this machine — the 25.0 you installed yourself, and the 26.2 bundled inside pip. The Specifier behaviour below comes from the standalone 25.0; pip's error messages come from pip 26.1.2's own resolver. The two do not treat pre-releases identically, and I come back to that below.
Also worth stating: my machine had no outbound network, so every pip error here was reproduced against hand-built local wheels using --no-index --find-links. No setuptools required — a wheel is just a zip with a METADATA file, which I generate directly.
1. The number of digits after ~= is the switch
~= is the easiest operator to get wrong, because its upper bound is decided entirely by how many numeric segments you typed.
PEP 440 puts it this way: for a given release identifier V.N, the compatible release clause is "approximately equivalent to the pair of comparison clauses" >= V.N, == V.*.
Measured on packaging 25.0:
| Specifier | 2.2 | 2.9 | 3.0 | 2.1.9 |
|---|---|---|---|---|
~=2.2 | match | match | no match | no match |
~=2.2.0 | match | no match | no match | no match |
That single .0 narrows the allowed range from all of 2.x down to 2.2.x. The patch segment is even more dramatic:
| Specifier | 1.4.5 | 1.4.6 | 1.4.9 | 1.5.0 |
|---|---|---|---|---|
~=1.4.5 | match | match | match | no match |
~=1.4.5.0 | match | no match | no match | no match |
~=1.4.5.0 admits only the 1.4.5.x line. Even 1.4.6 is excluded.
Two more details people miss. First, when a suffix is present it only affects the lower bound; the upper bound still follows the numeric segments:
SpecifierSet("~=2.2.post3") -> 2.2 no, 2.9 yes, 3.0 no
SpecifierSet("~=1.4.5a4") -> 1.4.5a4 yes, 1.4.9 yes, 2.0 no
PEP 440 has a dedicated sentence for this: if a pre-release, post-release or developmental release is named as V.N.suffix, "the suffix is ignored when determining the required prefix match".
Second, a single-segment version is a hard error:
>>> from packaging.specifiers import Specifier
>>> Specifier("~=1")
packaging.specifiers.InvalidSpecifier: Invalid specifier: '~=1'
The spec uses MUST NOT phrasing: "This operator MUST NOT be used with a single segment version number such as ~=1."
Here is a runnable comparison:
from packaging.specifiers import SpecifierSet
from packaging.version import Version
for spec in ["~=2.2", "~=2.2.0", "~=1.4.5", "~=1.4.5.0"]:
ss = SpecifierSet(spec)
row = ", ".join(
f"{c}={'Y' if Version(c) in ss else 'N'}"
for c in ["1.4.5", "1.4.6", "1.4.9", "1.5.0"]
)
print(f"{spec:12} -> {row}")
2. == zero padding, and why it let a local version through
== is not plain string equality. PEP 440 states that exactly one substitution happens: "The *only* substitution performed is the zero padding of the release segment".
So:
>>> Version("1.1.0") == Version("1.1")
True
And that is the only substitution. All of these are not equal to 1.1:
Version("1.1.dev1") # False
Version("1.1a1") # False
Version("1.1.post1") # False
Version("1.1.1") # False
Version("1.1+local") # False
The genuinely dangerous part is the local version label. PEP 440 is explicit: "If the specified version identifier is a public version identifier (no local version label), then the local version label of any candidate versions MUST be ignored when matching versions."
The consequence is that ==1.0 accepts all three of these:
ss = SpecifierSet("==1.0")
Version("1.0") in ss # True
Version("1.0+downstream1") in ss # True
Version("1.0+ubuntu.1") in ss # True
Version("1.0.1") in ss # False
If you are building a reproducible deployment and write ==1.0 to mean "exactly this upstream release", you are also accepting 1.0+downstream1, rebuilt by a downstream packager — and PEP 440 itself warns that a local version "does indicate that it may not contain the exact same code as the corresponding upstream release".
The spec's recommended form is ===1.0, which does plain string equality:
ss = SpecifierSet("===1.0")
Version("1.0") in ss # True
Version("1.0+downstream1") in ss # False
Version("1.0+ubuntu.1") in ss # False
But === has a counter-intuitive cost
PEP 440 describes === as an escape hatch for "non-PEP-440-compliant versions". The problem: if you actually use it to match a non-compliant string, the in operator raises instead of returning False:
>>> Version("foobar")
packaging.version.InvalidVersion: Invalid version: 'foobar'
>>> "foobar" in Specifier("===foobar")
Traceback (most recent call last):
...
File ".../packaging/specifiers.py", line 267, in prereleases
if Version(version).is_prerelease:
packaging.version.InvalidVersion: Invalid version: 'foobar'
The stack runs __contains__ → contains → reads the self.prereleases property, and reading that property parses the string into a Version first. It blows up before ever reaching ==='s string-comparison branch. So the correct use of === is only with compliant strings such as ===1.0 — do not point it at arbitrary historical version numbers.
3. > and < exclude same-version variants
> and < are not simple lexicographic comparisons. Each excludes a category of same-version variant.
For >, PEP 440 gives two rules: it must not admit a post-release of the given version (unless V is itself a post-release), and it must not admit a local version of the specified version. Measured:
SpecifierSet(">1.7") -> 1.7.1 yes, 1.7.0.post1 no, 1.7.post1 no
SpecifierSet(">=1.7") -> 1.7.0.post1 yes, 1.7.1 yes, 1.7+local yes
SpecifierSet(">1.0") -> 1.0 no, 1.0+downstream1 no, 1.0.1 yes
For <, the rule is that it must not admit a pre-release of the specified version, unless the specified version is itself a pre-release:
SpecifierSet("<1.7") -> 1.6.9 yes, 1.7.0a1 no, 1.7 no
SpecifierSet("<1.7rc1") -> 1.7a1 yes, 1.7rc1 no, 1.7 no
That explains a very common question: "I wrote <1.7 because I want to allow any preview before 1.7, but 1.7.0a1 will not install." The fix is to name a concrete rc as the upper bound — only <1.7rc1 lets 1.7a1 through.
4. Pre-releases are excluded by default (and 25.0 differs from 26.x)
The default rule: "Pre-releases of any kind, including developmental releases, are implicitly excluded from all version specifiers", unless they are already on the system, explicitly requested, or are the only version that satisfies the specifier.
Measured:
ss = SpecifierSet(">=1.0")
Version("1.0") in ss # True
Version("1.3.0a1") in ss # False
Version("2.0rc1") in ss # False
filter() rescues the "only a pre-release fits" case:
Specifier(">=1.2.3").filter(["1.2", "1.5a1"]) # ['1.5a1'] <- no final, pre-release wins
Specifier(">=1.2.3").filter(["1.3", "1.5a1"]) # ['1.3'] <- final exists, pre-release dropped
Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True) # ['1.3', '1.5a1']
⚠️ Here is the version trap. The packaging documentation now describes 26.x behaviour and documents contains(item, installed=True) as accepting already-installed pre-releases. On the 25.0 installed here, that parameter exists but does nothing — SpecifierSet(">=1.0", prereleases=False).contains("1.5a1", installed=True) returns False. More thoroughly, six methods the docs recommend — is_unsatisfiable(), to_range(), is_subset(), is_superset(), is_disjoint(), __replace__() — are entirely absent on 25.0:
from packaging.specifiers import SpecifierSet
for api in ["is_unsatisfiable", "is_subset", "is_superset",
"is_disjoint", "to_range", "__replace__"]:
print(api, hasattr(SpecifierSet, api))
Every line prints False. So check your version before copying code from the packaging docs.
5. You can recompute the spec's own ordering example
PEP 440 gives an unsorted list of 20 versions that defines the complete ordering. Sorting it here reproduces the document item for item:
from packaging.version import Version
pep = ["1.dev0", "1.0.dev456", "1.0a1", "1.0a2.dev456", "1.0a12.dev456",
"1.0a12", "1.0b1.dev456", "1.0b2", "1.0b2.post345.dev456",
"1.0b2.post345", "1.0rc1.dev456", "1.0rc1", "1.0", "1.0+abc.5",
"1.0+abc.7", "1.0+5", "1.0.post456.dev34", "1.0.post456",
"1.0.15", "1.1.dev1"]
got = [str(v) for v in sorted(Version(x) for x in pep)]
print(got == pep) # True
That makes a good regression baseline: if you change packaging versions, or implement in another language, run this once and you immediately know whether ordering behaviour changed.
Two related basics. Numeric components are compared by value, not as text — the spec requires that "All numeric components MUST be interpreted and ordered according to their numeric value, not as text strings".
Version("1.0a12") > Version("1.0a2") # True
String comparison would give the opposite answer, since "a12" < "a2".
And the epoch segment outranks everything: Version("1!0.1") > Version("99.99") is True. That is the whole point of epoch — it lets a project renumber itself and still sort after everything it released before.
Troubleshooting: six real errors and their root causes
Every message below was produced on this machine and can be reproduced with the commands shown.
Error 1: ResolutionImpossible, and the false "no matching distributions" line
This is the most valuable finding in the article. Here is the full multi-cause output:
ERROR: Cannot install -r r-cd.txt (line 1) and -r r-cd.txt (line 2) because these package versions have conflicting dependencies.
The conflict is caused by:
demo-c 1.0 depends on demo-b>=2.0
demo-d 1.0 depends on demo-b<2.0
Additionally, some packages in these conflicts have no matching distributions available for your environment:
demo-b
To fix this you could try to:
1. loosen the range of package versions you've specified
2. remove package versions to allow pip to attempt to solve the dependency conflict
ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
Symptom: pip claims demo-b has "no matching distributions available for your environment", yet the index has two versions of it.
Cause: I asked pip itself which candidates exist:
python -m pip index versions demo-b --no-index --find-links tmp/wheels
# demo-b (2.0)
# Available versions: 2.0, 1.0
demo-b plainly has 1.0 and 2.0 as real distribution files. The reason lives in pip's own factory.py, in the helper that decides whether a package has any candidates:
def _has_any_candidates(self, project_name: str) -> bool:
return any(
self.find_candidates(
project_name,
requirements={project_name: []}, # <- an empty list
incompatibilities={},
constraint=Constraint.empty(),
prefers_installed=True,
is_satisfied_by=lambda r, c: True,
)
)
requirements is empty, so the ireqs list inside find_candidates is empty too, and the very first line of _iter_found_candidates is if not ireqs: return (). any(()) is always False, so the negation is always True — meaning any package whose name appears in the conflict causes gets listed under "no matching distributions".
I called that private method directly to confirm. It does return False, and it does receive the empty list:
from pip._internal.resolution.resolvelib.factory import Factory
f = Factory.__new__(Factory)
print(f._has_any_candidates("demo-b")) # False
⚠️ One caveat I want to be precise about: I could not find an upstream issue covering this. So I am reporting measured behaviour on pip 26.1.2 plus the source-level cause, not presenting it as a community-acknowledged defect.
Copy-paste fix: do not trust that line. Verify with commands that actually query the index:
python -m pip index versions
python -m pip download --no-deps -d ./probe
And if you suspect an environment mismatch, retry with --python-version and --platform. A great many "no matching distribution" reports are really tag mismatches.
Error 2: with a single cause, the message takes a different shape
Same unsatisfiable constraint, but with only one cause pip takes a different branch. The output is far shorter and the Cannot install sentence is completely absent:
INFO: pip is looking at multiple versions of demo-a to determine which version is compatible with other requirements. This could take a while.
ERROR: Could not find a version that satisfies the requirement demo-b>=2.0 (from demo-a) (from versions: 1.0)
ERROR: No matching distribution found for demo-b>=2.0
Cause: pip's source has if len(e.causes) == 1: return self._report_single_requirement_conflict(req, parent), which short-circuits and prints the candidate list directly.
This is actually the better message. (from versions: 1.0) tells you exactly what the index holds, which is more actionable than the multi-cause template. So when resolution fails, narrow the conflict to a single source — try installing the one conflicting package on its own — and you get the more informative error.
Error 3: "Expected semicolon" is misleading
ERROR: Invalid requirement: 'flask=>=2.0': Expected semicolon (after name with no version specifier) or end
flask=>=2.0
^ (from line 1 of r3.txt)
Symptom: pip asks for a semicolon, but my file has no semicolon and does not need one.
Cause: the real mistake is a typo — => instead of >=. pip's requirement-line parser treats the = as "name finished, now expect the optional semicolon part", so it falls into the semicolon branch. The caret points at the second character of =>, which is the giveaway.
Copy-paste fix: do not fix this message literally. In practice "Expected semicolon" almost always means the operator is misspelled. Validate the whole file with Python instead:
from packaging.requirements import Requirement, InvalidRequirement
for i, line in enumerate(open("requirements.txt", encoding="utf-8"), 1):
line = line.split("#")[0].strip()
if not line:
continue
try:
Requirement(line)
except InvalidRequirement as e:
print(f"line {i} is bad: {line!r} -> {e}")
The same misleading message appears when you simply forget the version:
ERROR: Invalid requirement: 'requests==': Expected semicolon (after name with no version specifier) or end
Error 4: hash mismatch (a reproducible supply-chain guardrail)
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
demo-b==2.0 from file:///.../demo_b-2.0-py3-none-any.whl (from -r r-hash.txt (line 1)):
Expected sha256 deadbeef0000000000000000000000000000000000000000000000000000000000000000
Got 905dc748929ed9057f8cea05d55fa1d1a0f5ffa2e4b1c90fd833264a15376183
Cause: the --hash value was wrong. The Got line is the file's real sha256.
Copy-paste fix: compute the real value and paste it back.
python -m pip download --no-deps -d ./whl "demo-b==2.0"
python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" ./whl/demo_b-2.0-py3-none-any.whl
Error 5: forgetting `==` under --require-hashes
ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not:
demo-b from file:///.../demo_b-2.0-py3-none-any.whl (from -r r-h2.txt (line 1))
Cause: my requirements file listed demo-b with no version. In --require-hashes mode every dependency must be pinned with ==, including transitive ones.
Copy-paste fix: generate the hashed lock file with pip-tools instead of writing it by hand:
pip-compile --generate-hashes requirements.in -o requirements.txt
Error 6: eight invalid specifier forms, all rejected
This one needs no pip at all. All eight of these raise InvalidSpecifier:
from packaging.specifiers import SpecifierSet, InvalidSpecifier
for bad in ["~=1", "==1.0.dev1.*", "==1.0+foo.*", "== ", "1.0", "=>1.0", ">=1.0.*", "~=1.0+local"]:
try:
SpecifierSet(bad)
print(f"{bad!r:16} accepted")
except InvalidSpecifier as e:
print(f"{bad!r:16} rejected: {e}")
'~=1' rejected: Invalid specifier: '~=1'
'==1.0.dev1.*' rejected: Invalid specifier: '==1.0.dev1.*'
'==1.0+foo.*' rejected: Invalid specifier: '==1.0+foo.*'
'== ' rejected: Invalid specifier: '=='
'1.0' rejected: Invalid specifier: '1.0'
'=>1.0' rejected: Invalid specifier: '=>1.0'
'>=1.0.*' rejected: Invalid specifier: '>=1.0.*'
'~=1.0+local' rejected: Invalid specifier: '~=1.0+local'
Two notes. >=1.0.* is rejected because PEP 440 defines .* prefix matching only for == and !=. ~=1.0+local is rejected because local version identifiers are not permitted in a compatible release clause. And note that the "== " row shows '==' in the message — the trailing space is swallowed, so never rely on that string to see what you actually typed.
A constraint cheat sheet worth copying
| What you mean | Do not write | Write |
|---|---|---|
| Allow patch upgrades, never cross a minor | ~=1.4 | ~=1.4.0 |
| Allow only patches on the 1.4.5 line | ~=1.4.5 | ~=1.4.5.0 |
| Pin the pristine upstream release | ==1.0 | ===1.0 |
| Allow every preview before 1.7 | <1.7 | <1.7rc1 |
| Explicitly allow pre-releases | rely on the default | add --pre or prereleases=True |
Three ways to fail without any error at all
What binds these three together is that nothing raises, so they have to be caught by review.
One: >=2.0 looks like a pin but does not hold a major. Straight from the measurements above:
Version("3.1.2") in SpecifierSet(">=2.0") # True
Version("3.1.2") in SpecifierSet(">=2.0,<3") # False
Two: current docs ≠ your installed version. The packaging site documents the 26.x API; six of those methods are missing on 25.0. Run hasattr before you copy.
Three: outside --require-hashes there is no integrity checking by default. pip trusts the bytes the index hands it. If your deployment process cares, add --require-hashes or sign your artifacts.
The boundaries of this article
A few things I did not do, stated plainly:
- Outbound network was blocked, so there is no end-to-end test against real PyPI. All six pip errors were reproduced with hand-built local wheels plus
--no-index --find-links. The errors are real; the scenario is not a public index. - Two packaging versions coexist. pip 26.1.2 vendors 26.2; the Specifier behaviour above comes from standalone 25.0.
- Not covered: multi-interpreter and platform tag differences,
--no-binary, private index authentication, and behavioural differences in newer resolvers such as uv and poetry.
Two related articles on this site take the same no-network, measure-everything approach: Parquet and Arrow, tested covers the storage-encoding layer, and DuckDB 1.5.6 querying CSV with no database covers the query layer — useful context for why correct version constraints matter so much in a data pipeline.
Affiliate disclosure: this article contains no promotional links and points to no specific product.
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: