Introduction
Every time you run npm install, a small contract is being honoured. The package you pull in promises that a version number like 4.18.2 tells you something precise about what changed since 4.17.0 — and your lockfile and range syntax rely on that promise to keep your build reproducible. That contract is Semantic Versioning, almost always shortened to semver.
Semver is deceptively simple on the surface (three numbers separated by dots) and surprisingly easy to get wrong in practice. Most broken upgrades, mysterious CI failures, and “but it worked yesterday” moments trace back to a misunderstanding of what each number means or how range operators like ^ and ~ resolve. This guide walks through the rules as they actually behave in npm, Cargo, and other ecosystems, and points out the traps that bite real projects.
Tip: Use the Semver Calculator to bump a version, test what a range like ^1.2.3 actually allows, and check whether two versions are compatible — without guessing.
The anatomy of a version number
A semver version has three mandatory numeric parts and two optional sections:
1 . 4 . 2 - beta.1 + 20260620 │ │ │ │ │ MAJOR MINOR PATCH pre-release build metadata (required, in order) (optional) (optional, ignored for precedence)
The first three numbers carry the meaning, and the rule for incrementing them is the entire point of semver: each position signals a different kind of change to anyone depending on your code.
| Part | Bump it when… | Example |
|---|---|---|
| MAJOR | You make an incompatible (breaking) API change | 1.4.2 → 2.0.0 |
| MINOR | You add functionality in a backward-compatible way | 1.4.2 → 1.5.0 |
| PATCH | You make a backward-compatible bug fix only | 1.4.2 → 1.4.3 |
| pre-release | You ship an unstable preview before a release | 2.0.0-rc.1 |
| build | You attach build info that never affects resolution | 1.4.2+exp.sha.5114f85 |
A critical detail people miss: when you bump a higher position, every lower position resets to zero. Going from 1.4.2 to the next major is 2.0.0, not 2.4.2. A minor bump from 1.4.2 is 1.5.0, not 1.5.2. The lower numbers describe progress within the level above them, so they start fresh each time that level moves.
Version ranges: caret, tilde, and friends
You rarely pin an exact version in a manifest. Instead you declare a range that says “give me anything compatible with this.” The two operators you will see constantly are the caret (^) and the tilde (~), and confusing them is one of the most common sources of unexpected upgrades.
The caret allows changes that do not modify the left-most non-zero number. The tilde is stricter: with a minor version present, it only allows patch-level changes. Here is how the common operators resolve against a registry that has many published versions:
| Range | Means | Matches | Rejects |
|---|---|---|---|
| ^1.4.2 | >=1.4.2 <2.0.0 | 1.4.9, 1.7.0 | 2.0.0 |
| ~1.4.2 | >=1.4.2 <1.5.0 | 1.4.9 | 1.5.0 |
| 1.4.x | >=1.4.0 <1.5.0 | 1.4.0, 1.4.9 | 1.5.0 |
| 1.x | >=1.0.0 <2.0.0 | 1.9.9 | 2.0.0 |
| ^0.4.2 | >=0.4.2 <0.5.0 | 0.4.9 | 0.5.0 |
| * | any version | anything | nothing |
Notice the last caret row. ^0.4.2 does not behave like ^1.4.2. Because the major version is zero, the caret treats the minor as the “protected” position, so it only allows patch updates. This is intentional and ties directly into the most important rule for early-stage software, covered next.
The 0.x.y rule: anything can break
The semver spec carves out a special meaning for major version zero. Anything in the 0.y.z range is considered initial development, where the public API is not yet stable. The practical translation: during 0.x, a minor bump may contain breaking changes. There is no compatibility guarantee until you publish 1.0.0.
This is why tooling treats ^0.4.2 conservatively — allowing only patches — and why depending on a popular but unstable 0.x library with a wide range is risky. It also explains a release decision many maintainers agonize over: cutting 1.0.0 is not a marketing event, it is a promise. From that point on, breaking changes require a major bump, and your users will hold you to it.
Pre-release tags and how precedence is computed
A pre-release version appends a hyphen and dot-separated identifiers, like 2.0.0-alpha.1 or 2.0.0-rc.2. The key rule: a pre-release has lower precedence than its associated normal release. So 2.0.0-rc.1 < 2.0.0. This lets you publish previews that will never accidentally satisfy a normal range like ^1.0.0.
When comparing two versions, ecosystems follow the same ordering. Numeric identifiers compare numerically; alphanumeric ones compare lexically; and more identifiers win ties:
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-rc.1 < 1.0.0 // the final release outranks every pre-release
Build metadata (the + section) is the exception: it is completely ignored when determining precedence. 1.0.0+build.1 and 1.0.0+build.99 are considered equal for resolution. Use it for traceability, never for ordering.
Same spec, different defaults per ecosystem
The semver spec is shared, but tools layer their own conventions on top. Knowing the defaults saves you from surprises when you move between languages.
# npm: adds a caret range by default npm install lodash # → "lodash": "^4.17.21" # npm: pin exactly instead npm install lodash --save-exact # → "lodash": "4.17.21" # Cargo (Rust): a bare version IS a caret range serde = "1.0" # means ^1.0, i.e. >=1.0.0 <2.0.0 # Python (pip / PEP 440): compatible-release operator requests~=2.31.0 # >=2.31.0 <2.32.0 (like npm's ~)
The headline gotcha: in Cargo, writing "1.0" is not an exact pin — it is a caret range. Developers coming from a pin-by-default mindset are often surprised when a minor update lands in CI. In npm the opposite confusion happens: people assume ^ is “safe,” forgetting that a backward-compatible minor can still introduce a regression a maintainer did not intend to be breaking.
Deciding the next version: a worked example
The rules are easy to recite and harder to apply under pressure, so let us walk a realistic case. Suppose your library is currently at 2.3.1 and the changelog for the next release contains four entries. The temptation is to glance at the size of the diff and pick a number. The correct approach is to classify each change by its impact on a consumer, then let the highest-impact change decide the bump.
| Change | Impact on a consumer | Classification |
|---|---|---|
| Fixed an off-by-one in a date helper | Existing calls now return the correct result | PATCH |
| Added a new optional config flag | Old code keeps working; new code can opt in | MINOR |
| Improved internal logging | No visible API change at all | PATCH (or none) |
| Removed a deprecated export | Code importing it stops compiling | MAJOR |
Three of the four changes are perfectly backward compatible. If you stopped reading the changelog after the third line, you might reasonably ship 2.4.0 — a minor bump for the new flag. But the fourth line removes a deprecated export, and that is a breaking change: any dependent that still imports it will fail. One breaking change is enough to force the entire release to a major bump. The correct next version is 3.0.0, and the minor and patch reset to zero along the way.
This is the discipline that makes semver trustworthy: the version is determined by the most severe change in the release, not by how much work went into it or how the maintainer feels about it. A one-line removal can be a major release; a thousand-line internal refactor with no API change is a patch. If a deprecation feels too disruptive to release as a major, the answer is not to hide it in a minor — it is to keep the deprecated export working for one more cycle and remove it in a clearly communicated future major.
Common pitfalls that break dependents
Most semver pain is self-inflicted. These are the recurring mistakes worth internalizing, whether you publish packages or just consume them.
Shipping a breaking change in a minor or patch
Renaming an exported function, tightening a return type, or removing a default counts as breaking even if the diff looks small. If a reasonable dependent could fail, it is a major bump — not a patch.
Trusting ranges without a lockfile
A range describes what is allowed; the lockfile records what is installed. Commit your package-lock.json or Cargo.lock so CI and teammates resolve identically.
Forgetting the 0.x rule
A wide range on a 0.x dependency invites breaking minors. Pin tightly until the library reaches 1.0.
Using build metadata to order releases
It is ignored for precedence. If you need ordering, use a pre-release identifier instead.
When in doubt about whether a change is major, minor, or patch — or whether a given installed version satisfies a declared range — resolve it mechanically instead of arguing about it. That is exactly what the calculator below is for.
Related Tools on ByteJSON
Semver Calculator
Bump a version, test caret/tilde ranges, and check version compatibility.
UUID Generator
Generate unique identifiers for releases, records, and distributed systems.
Regex Tester
Test the patterns CI uses to validate version tags and release branches.
More Developer Guides
Browse tutorials on JSON, JWT, cron, regex, and other developer topics.
Written by Zhisan
Independent Developer · Last updated June 2026