# 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](https://peps.python.org/pep-0440/), and every tool that installs Python packages, [pip](https://pydevtools.com/handbook/reference/pip.md), [uv](https://pydevtools.com/handbook/reference/uv.md), 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](https://peps.python.org/pep-0440/).

A **requirement specifier** is the whole line: package name, optional extras, version specifier, and optional environment marker. It's defined by [PEP 508](https://peps.python.org/pep-0508/). A full example:

```text
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](https://peps.python.org/pep-0440/#local-version-identifiers) (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.2` means `>=1.4.2, ==1.4.*`. It accepts `1.4.2` and `1.4.9` but rejects `1.4.1`, `1.5.0`, `1.6.0`, and `2.0.0`.
- `~=1.4` means `>=1.4, ==1.*`. It accepts `1.4.0` and `1.9.9` but rejects `1.3.9` and `2.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.0ALPHA1` are all the same alpha release.
- `1.0rc1`, `1.0RC1`, `1.0c1`, `1.0.c.1` are all the same release candidate.
- `1.0.post1`, `1.0-1`, `1.0.rev1` are 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](https://pydevtools.com/handbook/explanation/what-is-pep-621-compatibility.md) in [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md).

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](https://peps.python.org/pep-0508/). 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](https://pydevtools.com/handbook/explanation/what-is-pep-735.md) 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](https://pydevtools.com/handbook/reference/uv.md) 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.2` matches `1.4.2.post1` because the post-release sorts higher than the base. If you wanted "exactly the initial 1.4.2 release," `>=1.4.2` isn't it.
- Exclusive ranges exclude suffixed versions of the target. `>1.4.2` does *not* match `1.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 than `1.4.2`, but `>1.4.2` still 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](https://pydevtools.com/handbook/reference/pyproject.toml.md) dependencies, under `[project.dependencies]` or `[project.optional-dependencies]`. Each entry is a full PEP 508 requirement specifier.
- [requirements.txt](https://pydevtools.com/handbook/reference/requirements.md), one requirement per line, same PEP 508 syntax plus pip-specific flags.
- [PEP 735 dependency groups](https://pydevtools.com/handbook/explanation/what-is-pep-735.md), under `[dependency-groups]` in `pyproject.toml`.
- Command-line installs, like `pip install "requests>=2.28,<3"` or `uv 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](https://pydevtools.com/handbook/reference/uv.md) 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](https://pydevtools.com/handbook/reference/pyproject.toml.md). 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](https://pydevtools.com/handbook/explanation/what-is-a-lock-file.md), 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.
- `lowest` and `lowest-direct`: the oldest version the range allows. `lowest` applies 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-direct` applies 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.

## Libraries and applications need different specifiers

The `--bounds lower` default and the lockfile-based workflow above reflect a principle that trips up well-meaning contributors: version specifiers in libraries serve *compatibility*, not *security*.

A library declares the widest range its code actually works with. `urllib3>=2` means "any 2.x release is compatible." An application pins exact versions through a [lockfile](https://pydevtools.com/handbook/explanation/what-is-a-lock-file.md), which records the single resolved version of every direct and transitive dependency. These two mechanisms handle different jobs:

| | Library (`pyproject.toml`) | Application (lockfile) |
|---|---|---|
| Purpose | Declare compatibility | Pin for reproducibility and security |
| Shape | Wide ranges (`>=2`) | Exact versions (`urllib3==2.6.3`) |
| Who controls it | Library maintainer | Application deployer |
| When it changes | API breaks or new minimum features needed | Every `uv lock --upgrade` or deploy cycle |

When a vulnerability is disclosed in a dependency, the correct response depends on which side of this line you're on. An *application* deployer upgrades the pinned version in their lockfile (e.g., `uv lock --upgrade-package urllib3`). A *library* maintainer generally should not bump their minimum from `>=2` to `>=2.6.3` just to "fix" the CVE.

The reason is amplification. A package like urllib3 has over 10,000 direct dependents. If every library bumped its minimum for a single vulnerability, thousands of libraries would need new releases, each triggering downstream rebuilds and lockfile churn, none of which actually protects anyone. The application deployer still has to upgrade their lockfile regardless of what the library's specifier says.

Tightening a library's lower bound can also *break* resolution for users who are on an older-but-compatible version for legitimate reasons, such as operating system constraints or internal testing policies. The library's code works fine with those versions; the specifier would reject them for a problem that lives in a different package entirely.

Two cases warrant reconsidering a library's lower bound: when a security fix introduces a backwards-incompatible change that the library's code depends on, and when the existing constraint blocks access to the fix entirely (for example, a library pinned to `urllib3>=1,<2` would prevent users from reaching a fix that only shipped in 2.x). Both of those are compatibility decisions, not security decisions.

## Mistakes that trip people up

A short catalog of traps that surprise people:

- `~=1` is a syntax error. The compatible release clause needs at least two components so it has a boundary to hold constant. Write `~=1.0` or `>=1,<2` instead.
- `^` is not PEP 440. Caret ranges come from Poetry and npm. Outside Poetry's own `pyproject.toml`, `^1.4` is invalid. Standard PEP 440 has no caret operator.
- Unquoted `>` redirects. `pip install foo>1.0` creates a file called `1.0` and installs the latest `foo`. Always wrap the specifier in quotes.
- `==1.4.2` isn't a literal pin. It matches `1.4.2+any.local.tag` too. Use `===1.4.2` if 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.0` won't pick up `2.0rc1` under pip unless you pass `--pre`. If a package only publishes release candidates, a naive range can look like "no matching version."

## Learn More

- [PEP 440, version identification and dependency specification](https://peps.python.org/pep-0440/)
- [PEP 508, dependency specification for Python software packages](https://peps.python.org/pep-0508/)
- [Version specifiers on packaging.python.org](https://packaging.python.org/en/latest/specifications/version-specifiers/)
- [PEP 621 project metadata](https://pydevtools.com/handbook/explanation/what-is-pep-621-compatibility.md)
- [PEP 735 dependency groups](https://pydevtools.com/handbook/explanation/what-is-pep-735.md)
- [pyproject.toml reference](https://pydevtools.com/handbook/reference/pyproject.toml.md)
- [requirements.txt reference](https://pydevtools.com/handbook/reference/requirements.md)
