RelicRELIC
Shipping better code

Semantic versioning (SemVer), explained

SemVer is the MAJOR.MINOR.PATCH scheme behind almost every package you install. Each number is a promise to whoever depends on your code. This walks through what bumps what, the strange rules for 0.x versions, pre-release and build tags, how version ranges behave in npm, pip and cargo, and the one promise SemVer quietly cannot keep.

Jordan Gibbs July 10, 2026 9 min read

A version like 4.11.2 looks like a serial number, but under semantic versioning it is a compressed promise. Read it as MAJOR.MINOR.PATCH and each position tells a consumer of your code exactly how nervous they should be about upgrading. SemVer, defined at semver.org and currently at version 2.0.0, is the shared grammar that lets a dependency resolver decide, on its own, which upgrades are safe to take.

The rules are precise, and a few of them surprise even people who have shipped packages for years. Here is the whole scheme, the edge cases, and where the promise gets thin.

What bumps what

Given a version MAJOR.MINOR.PATCH, you increment exactly one of the three when you publish a change to a public API, and the rule for which one is mechanical:

  • MAJOR when you make an incompatible API change. Anything that could break code written against the old version.
  • MINOR when you add functionality in a backward-compatible way. New features, nothing removed or changed underneath existing callers.
  • PATCH when you make a backward-compatible bug fix. Behaviour that was supposed to work now works, and nothing else moves.

When you bump a higher number, the lower ones reset to zero. A minor release after 1.4.7 is 1.5.0, not 1.5.7. A major after that is 2.0.0. This is the exact mapping that conventional commits automates: a fix commit is a PATCH, a feat is a MINOR, and a breaking change is a MAJOR, which is why the two conventions are so often adopted together.

The core idea SemVer sells is a social contract. If you depend on version 1.x of a library, the maintainer is promising that any 1.y.z you upgrade to will not break your code. Break that promise and you are obliged to ship a 2.0.0. The number is only meaningful because everyone agrees to honour it.

The 0.x rule, and why it changes everything

Version 0.x.y is a special zone, and the spec is blunt about it. While the major version is zero, the software is considered unstable and anything may change at any time. The public API should not be considered stable. There is no promise of backward compatibility at all.

That single rule is why the caret range behaves so differently for 0.x packages in npm. Normally ^1.2.3means “any 1.x.y at or above 1.2.3”, because minors are safe. But ^0.2.3 means only >=0.2.3 and <0.3.0. npm treats the first non-zero number as the one that must not change, so under 0.x the caret locks to the minor instead of the major:

how the caret narrows under 0.x
^1.2.3   allows >=1.2.3 <2.0.0    (minor + patch bumps)
^0.2.3   allows >=0.2.3 <0.3.0    (patch bumps only)
^0.0.3   allows >=0.0.3 <0.0.4    (nothing but that exact patch)

The lesson: a 0.x dependency will not pick up minor releases automatically under a caret, and it should not, because a 0.x minor is allowed to break you. If you publish a library, staying on 0.x is a signal that you are not ready to make the compatibility promise yet. Shipping 1.0.0 is the moment you commit to it.

Pre-release identifiers

You can append a pre-release tag with a hyphen: 1.0.0-alpha.1. This denotes a version that is unstable and might not satisfy the compatibility rules the final release will. Two things about pre-releases matter in practice.

First, precedence. A pre-release version has lower precedence than the associated normal release. So 1.0.0-alpha comes before 1.0.0. Among pre-releases, identifiers are compared field by field: numeric identifiers compare numerically, alphanumeric ones compare in ASCII sort order, and a larger set of fields wins when all the earlier ones are equal:

pre-release precedence, low to high
1.0.0-alpha  <  1.0.0-alpha.1  <  1.0.0-alpha.beta
             <  1.0.0-beta   <  1.0.0-beta.2  <  1.0.0-beta.11
             <  1.0.0-rc.1   <  1.0.0

Note beta.11 sorts after beta.2, because that field is numeric and compares by value rather than by ASCII. Second, most resolvers will not install a pre-release unless you ask for one explicitly, so tagging a release -rc.1keeps it out of everyone’s normal upgrades until it is ready.

Build metadata

After a plus sign you can attach build metadata: 1.0.0+20250714 or 1.0.0-beta+exp.sha.5114f85. It exists to carry things like a build number or a commit hash. The rule to remember is that build metadata is ignored when determining version precedence. Two versions that differ only in build metadata are the same version as far as ordering and range resolution are concerned. Use it for information, never to distinguish releases.

Version ranges in the wild

You rarely pin one exact version. Package managers let you express a range, and each ecosystem has its own dialect. The two npm operators do most of the work:

npm ranges
"lodash": "^4.17.21"   caret: >=4.17.21 <5.0.0   (minor + patch)
"lodash": "~4.17.21"   tilde: >=4.17.21 <4.18.0  (patch only)
"lodash": "4.17.21"    exact: only that version
"lodash": ">=4 <5"     explicit comparators

The caret is the npm default and follows the 0.x narrowing described above. The tilde is stricter, allowing patch bumps only. Other ecosystems express the same intent differently:

the same idea, other tools
# pip / PEP 440
requests>=2.31,<3.0        explicit lower + upper bound
requests~=2.31.0           compatible release: >=2.31.0, ==2.31.*

# Cargo (Rust) treats a bare version like npm's caret
serde = "1.0.195"          means ^1.0.195: >=1.0.195 <2.0.0
serde = "=1.0.195"         exact pin

A useful habit: for applications, commit a lockfile so builds are reproducible regardless of what the ranges allow. For libraries, keep ranges reasonably wide so you do not force conflicts on the projects that depend on you.

What SemVer cannot promise

Now the part the marketing leaves out. SemVer is a promise about intent, and intent is not enforced by anything. A maintainer labels a release a PATCH because they believe it is backward compatible. They can be wrong.

Plenty of “patch” releases have broken real code. A bug fix changes behaviour that someone was quietly relying on. A dependency of a dependency shifts under you. A performance fix alters timing that a test was implicitly assuming. None of these violate the letter of SemVer, and all of them can ruin your afternoon. The number records what the maintainer meant by the release. What the code actually does is a separate question.

  • Treat a green version range as a hypothesis, not a guarantee, and keep a test suite that would catch a bad upgrade.
  • Pin with a lockfile for anything you deploy, so an upgrade is a deliberate act you can review and roll back.
  • Read the changelog on any bump that matters, even a patch. This is exactly why a machine-generated changelog from conventional commits earns its keep.

None of this makes SemVer worthless. A world where the numbers carry a shared, honest meaning is enormously better than one where they do not, and most maintainers do honour the contract. Hold it as a strong convention and verify anyway.

Common mistakes

A handful of errors show up again and again, and each one quietly erodes the trust the numbers are supposed to carry. Knowing them by name makes them easy to avoid.

  • Shipping a breaking change as a minor or patch.The most damaging mistake, because it is the one that breaks other people’s builds without warning. If a change forces a consumer to edit their code, it is a MAJOR, full stop, even if it feels small to you.
  • Living on 0.x forever. Some projects treat 0.x as a permanent home to avoid the commitment of 1.0.0. That leaves every dependent unable to take minor updates safely under a caret. If your API is stable enough that people rely on it, ship 1.0.0 and make the promise explicit.
  • Re-tagging a published version. A version, once released, is immutable. If 1.4.2 is out and wrong, the fix is 1.4.3, never a quiet re-publish of 1.4.2. Registries and lockfiles assume a version number maps to exactly one set of bytes forever.
  • Leaking a leading zero into meaning. Numeric identifiers must not carry leading zeros, so 1.02.0 is not a valid version. Small, but resolvers will reject it.

None of these are exotic. They are the everyday ways a maintainer breaks the contract by accident, and every one is avoidable with a moment of thought at release time, or by handing the release to a tool that will not make them.

The whole scheme, on one screen

semver at a glance
MAJOR . MINOR . PATCH  - PRERELEASE  + BUILD
  |       |       |          |            |
  |       |       |          |            ignored for precedence
  |       |       |          lower precedence than the release
  |       |       backward-compatible bug fix
  |       backward-compatible new feature
  incompatible API change

0.x.y      anything may change; API is not stable
^1.2.3     >=1.2.3 <2.0.0     (npm caret, normal)
^0.2.3     >=0.2.3 <0.3.0     (npm caret, 0.x narrows to minor)
~1.2.3     >=1.2.3 <1.3.0     (patch only)

Learn the three numbers and the 0.x wrinkle and you can read any dependency line with confidence. Pair it with conventional commitsso the numbers set themselves, and document the compatibility you promise in your project’s README. If you find yourself comparing two version strings by hand to see what changed, a quick diff checker is faster than squinting.

Written by
Jordan GibbsFounder, Relic

Jordan Gibbs is the founder of Relic, an end-to-end encrypted, permanent, searchable memory for everything you copy. He writes widely about AI, agents, and practical tooling on Medium, where he is read by tens of thousands, and builds privacy-first software. Here he covers how everyday tools like the clipboard actually work, and how to use them without handing your data to someone else.

MediumGitHubLinkedIn
Part of
Shipping better code
Keep reading
Pillar·9 min

Conventional Commits, explained accurately

A small, strict format for commit messages that both people and tools can read. The spec accurately, why feat means MINOR and fix means PATCH, and how to adopt it without the process turning sour.

Read
8 min

A pull request template you can copy today

A Markdown file your host pre-fills into every new PR. Three templates to copy, where the file lives on GitHub and GitLab, and what separates a checklist people use from one they delete.

Read
7 min

A README template that people actually read

The front door of a project, and most visitors decide within the first screen. A copyable README skeleton that leads with the quick start, plus the reasoning and a minimal variant.

Read
developerversioningconventions