DevOps · Release Engineering
Semantic Versioning as a Compatibility Contract
What each of the three version numbers is allowed to promise, how the ordering rules work, and why the compatible-change rule only holds for the parts of a surface a project actually constrains.
Version numbers look like labels but behave like a protocol. The moment a dependency resolver, a lockfile, or a package registry reads a version string, that string becomes an input to an algorithm: it is parsed, ordered, and compared against a constraint expression. Semantic Versioning is a written attempt to make that algorithm carry meaning — a major bump is supposed to warn before code breaks, a patch bump is supposed to be safe to take without reading anything.
The specification is short and the interesting part is not the three numbers. It is the question of what “the public API” means for a given artifact, because the entire promise is scoped to it.
Three numbers, two rules
A version is MAJOR.MINOR.PATCH, each a non-negative integer without leading zeroes. The rules are stated as consequences of compatibility:
MAJORincrements when the public API changes in an incompatible way.MINORincrements when functionality is added in a backward-compatible way, and resetsPATCHto zero.PATCHincrements when a bug is fixed backward-compatibly, and resets nothing above it.
Two rules dominate practice. The first is that no two different artifacts may share a version. The spec is explicit that once a version is released, its contents must not change; a broken release is superseded by a new version, never repaired in place. That rule is what every content-addressed registry and every lockfile’s integrity hash depends on, and it is broken constantly by registries that permit re-publishing the same number.
The second is the zero-major convention. Every version beginning 0. is exempt: minor bumps may break things, because the API is deemed unstable. Projects take this as licence, and dependents take it as a warning to pin exactly.
Ordering is part of the specification
A resolver needs a total order, so the spec defines precedence precisely. Split on dots into numeric identifiers, compare numerically; 1.10.0 is greater than 1.9.0 because the comparison is on integers, not on strings. This single rule breaks more naive comparators than any other, since lexicographic ordering puts 1.10.0 before 1.9.0.
Pre-release and build metadata attach with - and +:
1.0.0-alpha
1.0.0-alpha.1
1.0.0-alpha.beta
1.0.0-beta.2
1.0.0-beta.11
1.0.0
Precedence walks the dotted identifiers of the pre-release field from left to right. Numeric identifiers compare numerically; alphanumeric identifiers compare in ASCII order; numeric is always lower than alphanumeric; a shorter identifier list that is a prefix of a longer one is lower. A version with a pre-release field is always lower than the same version without one. That final rule is what lets a release candidate be published to the same namespace as the release and still sort below it.
Build metadata after + is ignored for precedence entirely. 1.0.0+build.7 and 1.0.0+build.8 are equal in ordering, which means build metadata cannot distinguish two artifacts for a resolver that compares by precedence. It exists for identification, not sequencing.
The consequence worth internalising: 1.0.0-rc.1 sorts below 1.0.0, and also below 0.9.9? No — the comparison starts at the major field, so 1.0.0-rc.1 is still higher than any 0.x.y. Pre-release ordering only matters between versions whose major/minor/patch triple is identical.
What the contract is scoped to
The specification says the public API is whatever the artifact declares. There is no requirement that the declaration be machine-readable, and most projects never write it down as a list of exported symbols. This gap produces the recurring dispute about whether a change is major.
For a library, the safe definition is the set of names and signatures that a dependent can reference without the library’s own documentation. Removing an exported function is major. So is adding a required parameter to a function that had optional ones, changing a return type to a narrower one, raising an exception where none was raised, or tightening an input validation rule so that a previously accepted value is rejected. Making an interface stricter is a breaking change in the direction most people miss: callers depend on the old leniency.
For an application or a service, the version numbers describe something else entirely — the deployment unit, or the declared API surface. A service that adds a field to a JSON response is minor if clients tolerate unknown fields and major if a generated client rejects them. The same diff lands differently depending on the consumer’s parser, which is why protocol evolution tends to use additive rules that make unknown fields ignorable, an idea taken up in immutable artifact coordinates and provenance.
There is a category the spec does not name: behaviour that no signature expresses. A function that previously returned results in insertion order and now returns them in hash order has an unchanged signature and an incompatible contract. Semantic versioning has no mechanism for behavioural compatibility, and no tool can derive it from a diff.
Version constraints are a separate language
The version itself is a single token. What dependents write is a constraint expression, and that expression is not part of the SemVer specification. Every ecosystem invents its own dialect, and the dialects disagree about the details that matter.
A caret range renders as ^1.2.3 in one ecosystem and means >=1.2.3 <2.0.0. The same-looking requirement expresses “compatible with 1.2.3 under the major rule”, which is exactly the SemVer promise lifted into a resolver predicate. A tilde range narrows to patch-level movement. An exact pin ignores the promise entirely.
The dialects differ on whether a range matches pre-release versions at all. A common convention is that a range without a pre-release component does not match any pre-release version, so ^1.0.0 will not select 1.5.0-beta.1. This prevents a resolver from silently picking up a candidate build, and it also means the pre-release channel needs its own explicit constraints. The mechanics of publishing and promoting those candidates are covered in pre-release channels and promotion.
Empty-major and zero-minor edge cases also leak: ^0.2.3 commonly means >=0.2.3 <0.3.0, because a zero-major artifact is assumed unstable at the minor boundary. Projects that publish 0.x indefinitely therefore give their consumers a constraint language that behaves differently from the one they expect.
Where the convention fails
Three failure modes recur, and none of them is a bug in the specification.
The version is decoupled from the artifact. Build systems that stamp a version from the current date, or from a pipeline counter, produce a monotone sequence that carries no compatibility signal at all. That is a legitimate choice, but the number can no longer be used to grant automatic upgrades, and every consumer must pin.
The surface changes without a version change. A dependency’s transitive dependency moves a major version, and the installed tree changes while the top-level version does not. The version of a package describes its own surface, not the surface of the closure it pulls in. Lockfiles exist because of this gap, and the resolution mechanics are the subject of lockfiles and version resolution.
A minor bump is treated as an entitlement. A resolver that selects the highest satisfying version on every install will eventually install a minor release that carries an undeclared break. The convention’s guarantee is only as strong as the maintainer’s discipline in classifying the change, and classification is a judgement made by a person reading a diff, often quickly.
A related gap sits between the version and the tag that carries it. Git tags are mutable by default; a v1.4.0 tag can be deleted and re-pointed at a different commit, and a registry that accepts an upload under a number that already exists can host two different byte strings under one version. Both are outside the specification’s reach, since the spec speaks about artifacts, not about the transports that carry them. The mitigation is procedural: signed tags, immutable registry namespaces, and a release gate that refuses to publish a coordinate that exists.
The practical reading: version numbers are a claim about the future behaviour of an artifact, made voluntarily, and enforced only to the extent that the version is immutable and the surface is written down. Everything a release process does around changelogs, tags, and gates exists to make that claim checkable rather than aspirational.
References
- Semantic Versioning 2.0.0 — SemVer project
- SemVer 2.0.0 BNF grammar — SemVer project
- The Package Manager Versioning Problem — Node.js package resolution documentation — npm, semantic versioning