What is a version specifier?
Every time you write requests>=2.28,<3 or numpy~=1.26, you’re writing a version specifier. It’s the piece of syntax that tells a resolver which releases of a package are acceptable and which are off limits. The rules come from PEP 440, and every tool that installs Python packages, pip, uv, Poetry, PDM, has to follow them.
Most of the operators behave the way the symbols suggest. The rules that catch people live in the corners: the compatible release clause ~=, the arbitrary equality operator ===, version normalization, and how release suffixes interact with ordering. This page walks through each one.
Two names for two different pieces of syntax
The two terms get used interchangeably in conversation, but they name different slices of syntax.
A version specifier is just the operator-and-version part. In requests>=2.28,<3, the version specifier is >=2.28,<3. It’s defined by PEP 440.
A requirement specifier is the whole line: package name, optional extras, version specifier, and optional environment marker. It’s defined by PEP 508. A full example:
requests[socks] >= 2.28, < 3 ; python_version >= "3.9"That string contains a name (requests), an extras list ([socks]), a version specifier (>=2.28,<3), and an environment marker (python_version >= "3.9"). Casual speech calls the whole thing a “version specifier,” and most of the time no one is confused. If you’re reading a spec or a resolver error message, though, the distinction matters.
Reading every PEP 440 operator
PEP 440 defines eight operator clauses. Each example here has been verified against the packaging library.
== version matching
Matches an exact release. requests==2.31.0 accepts only 2.31.0.
It also supports a wildcard: ==1.4.* accepts any release in the 1.4 series (1.4.0, 1.4.1, …, 1.4.99) and rejects 1.3.9 or 1.5.0.
One subtlety: ==1.4.2 also matches 1.4.2+local, because PEP 440 treats a local version identifier (the part after +) as compatible with the public version. Use the arbitrary equality operator === if you need to pin the literal string.
!= version exclusion
Rejects a specific release. django!=4.2.1 accepts every version except 4.2.1. Wildcards work here too: !=1.4.* rejects the entire 1.4 series.
<= and >= inclusive ordered comparison
>=2.0 accepts 2.0 and everything after it. <=1.9 accepts 1.9 and everything before. These are the operators you reach for most often when expressing “at least version X” or “no newer than version Y.”
< and > exclusive ordered comparison
Same as the inclusive versions but without the boundary. <2.0 rejects 2.0 itself; >1.0 rejects 1.0 itself.
PEP 440 adds one quirk worth knowing: <2.0 also excludes 2.0rc1, 2.0b2, 2.0.post1, and any other suffixed release of 2.0, because exclusive ordered comparisons are defined to exclude every release that shares the target’s public version, regardless of pre-, post-, or dev- suffixes. The full rule lives under “How release suffixes affect matching.”
~= compatible release
The compatible release clause is shorthand for “this version or later, but don’t cross the next boundary.” The boundary is the second-to-last component.
~=1.4.2means>=1.4.2, ==1.4.*. It accepts1.4.2and1.4.9but rejects1.4.1,1.5.0,1.6.0, and2.0.0.~=1.4means>=1.4, ==1.*. It accepts1.4.0and1.9.9but rejects1.3.9and2.0.0.
~= requires at least two components. ~=1 is a syntax error, because there’s no “previous component” to hold constant.
=== arbitrary equality
The escape hatch for versions that aren’t valid PEP 440 strings. ===1.4.2+local.build.7 matches that literal string and nothing else. No normalization, no wildcard expansion, no local-version compatibility, no leniency of any kind.
Warning
=== skips PEP 440 normalization, which means it also skips the safety nets. ===1.4.2 won’t match 1.4.2.0 even though PEP 440 considers those equivalent. Only reach for === when you’re pinning to a hand-built or vendor-tagged release that the normal operators can’t express.
Version normalization and ordering
Before any operator compares two versions, PEP 440 normalizes both sides. Two strings that look different can end up treated as the same version, and a few conventions that feel natural turn out to be spellings of the same thing.
Trailing zeros are equal
1.4.2, 1.4.2.0, and 1.4.2.0.0 are all the same version. ==1.4.2 matches every one of them. ==2.0 matches 2.0.0. This is why numpy==2.0 doesn’t behave like a strict pin to exactly three characters worth of .0 components.
Case and alias spellings collapse
PEP 440 recognizes several spellings of the same concept:
1.0a1,1.0.a1,1.0-a-1,1.0alpha1,1.0ALPHA1are all the same alpha release.1.0rc1,1.0RC1,1.0c1,1.0.c.1are all the same release candidate.1.0.post1,1.0-1,1.0.rev1are all the same post-release.
A project publishing 1.0.0-rc1 on PyPI gets normalized to 1.0.0rc1 in metadata. Readers who see >=1.0rc1 in one file and >=1.0c1 in another should treat them as identical.
Local versions use the + suffix
A local version is the +something tail on a version string, used to mark a build that came from somewhere other than a clean PyPI release. 1.4.2+cpu, 1.4.2+ubi8, and 1.4.2+git.abc123 are all local versions of 1.4.2. PyTorch is the most visible user: its CUDA wheels carry local versions like 2.1.0+cu121 to distinguish builds against different CUDA toolkits.
PEP 440 treats local versions as compatible with the public version they attach to. ==1.4.2 matches 1.4.2+cpu, which is usually what you want (you asked for 1.4.2, you got a local build of it). The exception is ===, which compares the literal string and refuses to normalize.
Epochs handle renumbered packages
On rare occasions a project needs to renumber its releases, usually because an earlier scheme painted the project into a corner. PEP 440 supports this with version epochs, written with a ! prefix. 1!1.0 is “epoch 1, version 1.0,” and 1!1.0 sorts after every version in epoch 0 regardless of the numbers that follow. ==1!1.0 does not match plain 1.0, even though the release segment is identical.
Epochs show up most often in packages that used to use date-based versions and switched to semantic versioning, or the reverse. Most projects will never need one, but the ! syntax exists when they do.
Combining specifiers with commas
Comma-separated specifiers are AND’d together. >=1.0,<2.0 means “at least 1.0 AND less than 2.0,” the usual way to express a range inside a single major version.
There is no OR operator in PEP 440. If you need “accept either 1.x or 3.x but not 2.x,” you express it as the intersection of exclusions: >=1.0,!=2.*,<4. Resolvers combine multiple requirement lines from different files the same way: by intersecting them.
Extras and environment markers
A requirement specifier can carry two extra pieces that aren’t part of the version specifier itself.
Extras select optional dependency groups declared by the package. requests[socks] installs requests plus whatever its socks extra pulls in (in this case, PySocks for SOCKS proxy support). Extras come from the package’s own metadata, defined today under PEP 621 project metadata in pyproject.toml.
Environment markers gate a requirement on properties of the install environment. pytest; python_version >= "3.10" installs pytest only on Python 3.10+. Markers support python_version, sys_platform, platform_machine, implementation_name, and several others, all defined in PEP 508. If you want to manage groups of dev, test, docs, or lint dependencies inside pyproject.toml rather than as package extras, PEP 735 dependency groups are the modern home for them.
How release suffixes affect matching
A PEP 440 version can carry three flavors of suffix after the release segment, and each one sorts in a different place relative to the base version. This is the part of PEP 440 that surprises almost everyone on first contact.
Pre-releases sort before the base release
Pre-releases (2.0.0a1, 2.0.0b3, 2.0.0rc1) come out in calendar order before the stable 2.0.0, and PEP 440 orders them that way: 2.0.0rc1 < 2.0.0. By default, resolvers hide pre-releases from normal ranges to spare users from accidentally installing alpha builds. >=2.0 won’t pick up 2.0.0rc1 without explicit opt-in.
Tools differ on what counts as opt-in. pip excludes pre-releases unless you pass --pre, or unless the version specifier itself mentions a pre-release (>=2.0.0a1 signals “I already expect to see pre-releases of 2.0”). uv allows pre-releases automatically when no stable release satisfies the range, and exposes a --prerelease flag with five modes (disallow, allow, if-necessary, explicit, if-necessary-or-explicit) for finer control. Poetry and PDM each have their own defaults. Check your resolver’s docs before assuming a particular behavior.
Post-releases sort after the base release
A post-release (.post1, .post2) is a fix made after the base version shipped, usually to correct a packaging or metadata mistake without bumping the version number. PEP 440 orders post-releases after the base: 1.4.2 < 1.4.2.post1 < 1.4.3. This has two consequences that trip people up:
- Inclusive ranges include post-releases.
>=1.4.2matches1.4.2.post1because the post-release sorts higher than the base. If you wanted “exactly the initial 1.4.2 release,”>=1.4.2isn’t it. - Exclusive ranges exclude suffixed versions of the target.
>1.4.2does not match1.4.2.post1, because PEP 440’s rule for exclusive ordered comparisons excludes every suffixed release that shares the target’s release segment. The post-release sorts higher than1.4.2, but>1.4.2still rejects it.
Dev releases sort before everything
A dev release (1.4.2.dev0, 1.4.2.dev4) is an intra-release snapshot for developers testing unreleased work. PEP 440 sorts dev releases before their base: 1.4.2.dev0 < 1.4.2.a1 < 1.4.2. So >=1.4.2 rejects 1.4.2.dev0 even though the numbers look identical, because 1.4.2.dev0 is ordered lower than the final release. Dev releases are even more aggressively hidden than pre-releases, and consumer resolvers almost never install them unless you name them explicitly.
Where version specifiers show up
Anywhere a tool needs to know which releases of a package are acceptable:
- pyproject.toml dependencies, under
[project.dependencies]or[project.optional-dependencies]. Each entry is a full PEP 508 requirement specifier. - requirements.txt, one requirement per line, same PEP 508 syntax plus pip-specific flags.
- PEP 735 dependency groups, under
[dependency-groups]inpyproject.toml. - Command-line installs, like
pip install "requests>=2.28,<3"oruv add "numpy~=1.26".
Important
Always quote version specifiers on the command line. Unquoted, pip install requests>=2.28 gets parsed by the shell as a redirection, and you’ll end up with an empty file named =2.28. Quotes stop the shell from touching <, >, *, and !.
How uv picks and writes version specifiers
A specifier defines a range. Two uv flags control what happens inside that range: --bounds decides what specifier to write when a dependency is added, and --resolution decides which version the resolver lands on when it reads one.
uv add --bounds chooses the specifier
uv add requests installs the package and writes an entry into [project.dependencies] in your pyproject.toml. The --bounds flag (default: lower) controls the shape of that entry:
--bounds |
Generated specifier | When to use |
|---|---|---|
lower (default) |
requests>=2.33.1 |
Libraries and most applications. Declares the tested minimum, lets the resolver pick the newest compatible release. |
major |
requests>=2.33.1,<3.0.0 |
Projects that want to prevent accidental uptake of a major-version release without review. |
minor |
requests>=2.33.1,<2.34.0 |
Rare. Projects that distrust minor-version upgrades (for example, tools that hook into internal APIs). |
exact |
requests==2.33.1 |
Applications that want the pyproject.toml itself to pin, rather than relying on the lockfile. |
The lower default reflects modern Python packaging practice: libraries declare minimums and let the resolver move forward. Applications that need reproducibility get it from uv’s lockfile, which pins the entire dependency tree, not from tight specifiers in pyproject.toml.
Pass --raw to skip --bounds entirely and write the specifier exactly as typed: uv add --raw "requests~=2.33" keeps the compatible-release form.
--resolution picks the version inside the range
A specifier like >=2.0,<3 describes a range, not a single version. uv’s --resolution flag controls which version the resolver actually picks:
highest(default): the newest version the range allows. This matches pip’s default and most people’s mental model.lowestandlowest-direct: the oldest version the range allows.lowestapplies to every package in the tree, direct and transitive, which is good for stress-testing but often impractical because transitive dependencies drag back to 2019.lowest-directapplies only to the packages you declared, letting their transitive dependencies float to the latest.
lowest-direct is the setting worth remembering. It answers the question “do the >=X bounds I wrote actually reflect what my code needs?” without requiring every transitive dependency to also roll back. A common test-matrix pattern runs uv sync --resolution lowest-direct alongside the default to catch accidental reliance on features newer than the declared minimum.
Mistakes that trip people up
A short catalog of traps that surprise people:
~=1is a syntax error. The compatible release clause needs at least two components so it has a boundary to hold constant. Write~=1.0or>=1,<2instead.^is not PEP 440. Caret ranges come from Poetry and npm. Outside Poetry’s ownpyproject.toml,^1.4is invalid. Standard PEP 440 has no caret operator.- Unquoted
>redirects.pip install foo>1.0creates a file called1.0and installs the latestfoo. Always wrap the specifier in quotes. ==1.4.2isn’t a literal pin. It matches1.4.2+any.local.tagtoo. Use===1.4.2if you need a byte-for-byte match, and accept that you’ll lose local-version compatibility and normalization.- Pre-releases are invisible by default.
>=2.0won’t pick up2.0rc1under pip unless you pass--pre. If a package only publishes release candidates, a naive range can look like “no matching version.”