Language Runtimes · Packaging and Distribution

Version Precedence and the Semantics of Range Operators

How version strings are ordered, why caret and tilde ranges expand the way they do, and where precedence rules diverge between ecosystems badly enough to change an install.

Every resolver compares versions, and every range operator is defined in terms of that comparison. The comparison is a total order on strings that were never designed as numbers, and the range operators are sugar that expands into bounds on that order. Getting either wrong produces installs that look fine until two constraints meet.

Ordering is defined by the spec, not by the string

Semantic Versioning fixes a grammar and a precedence rule. A version is MAJOR.MINOR.PATCH, optionally followed by a hyphen and a dot-separated prerelease, optionally followed by a plus and dot-separated build metadata. Precedence compares major, then minor, then patch, each as an integer, so 1.10.0 is greater than 1.9.0 and string comparison would be wrong. A version with a prerelease has lower precedence than the same version without one, so 1.0.0-rc.1 < 1.0.0. Build metadata is ignored entirely for precedence, which means 1.0.0+build.5 and 1.0.0+build.6 are equal in ordering and cannot be distinguished by a range.

Prerelease identifiers compare piecewise. Numeric identifiers compare numerically; alphanumeric identifiers compare in ASCII lexical order; numeric identifiers always have lower precedence than alphanumeric ones, which is why 1.0.0-1 precedes 1.0.0-alpha. A longer prerelease with an equal prefix has higher precedence, so 1.0.0-alpha.1 > 1.0.0-alpha.

These rules are precise but narrow. Semver says nothing about epochs, post-releases, local version labels, or how many components a version may have. Each ecosystem fills the gap differently, and the differences are not cosmetic.

The comparison itself is small enough to state exactly, and doing so makes the prerelease rules concrete.

def key(v):
    core, _, pre = v.partition("-")
    core = core.split("+")[0]               # build metadata is not ordered
    pre = pre.split("+")[0]
    nums = tuple(int(x) for x in core.split("."))
    if not pre:
        return (nums, (1,))                 # a release outranks its prereleases
    ids = []
    for part in pre.split("."):
        ids.append((0, int(part), "") if part.isdigit() else (1, 0, part))
    return (nums, (0, tuple(ids)))

def precedes(a, b):
    return key(a) < key(b)

The (1,) against (0, ...) sentinel is what implements “a prerelease has lower precedence than the release it precedes”. Numeric identifiers carry tag 0 and alphanumeric carry tag 1, so 1.0.0-1 < 1.0.0-alpha falls out of tuple comparison without a special case.

PEP 440 defines a different grammar for Python: an optional epoch before a colon, a release segment of arbitrary length, then optional pre-release, post-release, development-release, and local-version segments. 1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1. The epoch exists so a project can renumber without ever decreasing: 2!1.0 outranks 1!99.0. The local segment after a plus is ordered by its own rules and is dropped when a requirement without a local label is matched against it, so 1.0+cu118 satisfies ==1.0. A requirement that does name a local segment matches only that exact label.

Debian’s scheme is simpler in structure and stranger in effect. A version is [epoch:]upstream[-revision], the epoch defaults to zero, and comparison splits the upstream and revision parts and compares them by a rule that treats digit runs numerically and everything else by a modified ASCII order in which tilde sorts before everything, including the empty string. That single rule is why 1.0~rc1 < 1.0: the tilde lets a maintainer place a prerelease below the final version without inventing a new upstream number.

Go takes a fourth position. Module versions are semver, but the major version appears in the module path once it reaches 2, so example.com/lib/v2 and example.com/lib are distinct modules rather than versions of one. The import path is part of the identity, which removes an entire class of ambiguity at the cost of requiring a path change at every major release.

What caret and tilde actually expand to

Range operators are abbreviations for interval bounds, and the bounds are computed from the version on the left.

In npm’s semver implementation, ^1.2.3 means >=1.2.3 <2.0.0. ^0.2.3 means >=0.2.3 <0.3.0, and ^0.0.3 means >=0.0.3 <0.0.4. The rule is that the caret allows changes to the leftmost non-zero component, which encodes the assumption that a zero major version has no stability guarantee. ~1.2.3 means >=1.2.3 <1.3.0, and ~1.2 means >=1.2.0 <1.3.0, so tilde permits patch-level movement only.

Two consequences follow. First, the same operator has different width depending on the version it is applied to, so a library that bumps from 0.9 to 1.0 silently widens its own compatibility claim from one minor line to an entire major line. Second, a bare 1.2 in npm means >=1.2.0 <1.3.0, not >=1.2.0 <2.0.0, while in some other ecosystems a two-component requirement is treated as a prefix match. The operator is not portable; the meaning lives in the resolver’s documentation.

Go’s go.mod uses a different default. A requirement written without an operator is a minimum, and the build selects the maximum of all minimums for a module across the graph, a rule usually described as minimal version selection. Selecting the minimum that satisfies every requirement is the opposite of npm’s newest-first default, and it has a real effect: adding a dependency cannot silently upgrade an unrelated module, because the selected version is a function of the declared minimums rather than of what the registry currently holds. The cost is that a security fix must be requested explicitly by raising a minimum somewhere.

Ranges are sets, and sets do not always intersect

A range denotes a set of versions, and every constraint in the graph contributes another set. Resolution intersects them. The intersection of >=1.0,<2.0 and >=1.5,<1.9 is [1.5,1.9). The intersection of ^1.2 and ^2.0 is empty, and that emptiness is the conflict the resolver must report or route around.

Exclusions make the set non-contiguous. A requirement of the form >=1.0,<2.0,!=1.4.2 removes a single point, and a resolver that models ranges as (lower, upper) pairs will select 1.4.2 and then fail late. Correct implementations keep a sorted list of intervals and subtract the excluded version from the interval containing it. The same structure handles the PEP 440 case where a pre-release is excluded by default: pip does not consider pre-releases unless the requirement itself names one or the user opts in, which means the effective candidate set depends on a policy flag outside the version grammar.

Prerelease policy is the most common source of disagreement between a manifest author’s intent and a resolver’s behaviour. Under semver, 1.0.0-rc.1 is a legal version less than 1.0.0, and a range of >=1.0.0-alpha <1.0.0 includes it. A range of >=1.0.0 does not. In Python, >=1.0.0rc1 is required to see release candidates at all. A dependency that publishes only pre-releases is invisible to a requirement written without one, and the resulting error message names a package that plainly exists.

Where comparison order leaks into other machinery

Version order is used for more than constraint checking, and each additional use inherits its edge cases.

Lockfile diffing and update selection both sort versions, so a resolver that treats build metadata as significant will report a change between two builds of the same release. Content-addressed artifact names avoid the problem by keying on a hash rather than a version, as described in Content Addressing and What Artifact Hashes Actually Prove, but human-facing reports still sort by version.

Changelog and advisory matching is a second leak. A security advisory states affected ranges in the ecosystem’s grammar, and a scanner must implement that grammar exactly. A scanner that compares Debian versions with semver logic will mis-order any package using a tilde or an epoch, and will therefore either miss affected installs or flag unaffected ones.

Finally, sorting versions for display requires a tiebreak that the spec does not provide. Two versions equal under precedence but different in build metadata must be ordered somehow, and any choice is arbitrary. The safe approach is to treat equal-precedence versions as interchangeable when resolving and to preserve the original string when displaying.

The practical rule is that a version string is a token in a grammar, not a number, and the grammar belongs to the ecosystem. Code that parses versions should carry the parser for the ecosystem it serves rather than a general-purpose comparator, and manifests should prefer explicit interval bounds over caret when the intended width of compatibility is not exactly what the caret expands to. The constraints these parsers produce are then consumed by the search described in Dependency Resolution as Backtracking Search Over Version Sets, which is where a wrongly widened range turns into a real upgrade.

References

  1. Semantic Versioning 2.0.0 — semver.org
  2. PEP 440: Version Identification and Dependency Specification — Python Software Foundation
  3. Debian Policy Manual, Chapter 5: Control files and their fields — Debian Project
  4. Go Modules Reference: Version queries — The Go Programming Language