Semantic Versioning (Semver): A Practical Guide

11 min readWeb Development

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.

PartBump it when…Example
MAJORYou make an incompatible (breaking) API change1.4.2 → 2.0.0
MINORYou add functionality in a backward-compatible way1.4.2 → 1.5.0
PATCHYou make a backward-compatible bug fix only1.4.2 → 1.4.3
pre-releaseYou ship an unstable preview before a release2.0.0-rc.1
buildYou attach build info that never affects resolution1.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:

RangeMeansMatchesRejects
^1.4.2>=1.4.2 <2.0.01.4.9, 1.7.02.0.0
~1.4.2>=1.4.2 <1.5.01.4.91.5.0
1.4.x>=1.4.0 <1.5.01.4.0, 1.4.91.5.0
1.x>=1.0.0 <2.0.01.9.92.0.0
^0.4.2>=0.4.2 <0.5.00.4.90.5.0
*any versionanythingnothing

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.

ChangeImpact on a consumerClassification
Fixed an off-by-one in a date helperExisting calls now return the correct resultPATCH
Added a new optional config flagOld code keeps working; new code can opt inMINOR
Improved internal loggingNo visible API change at allPATCH (or none)
Removed a deprecated exportCode importing it stops compilingMAJOR

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

Z

Written by Zhisan

Independent Developer · Last updated June 2026