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 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:
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:
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:
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:
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
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.