The Complete Guide to Semantic Versioning
What Is Semantic Versioning?
Semantic versioning — commonly abbreviated as semver — is a versioning scheme that assigns meaning to each number in a version string. Rather than arbitrary build numbers or marketing-driven releases, semver uses a strict MAJOR.MINOR.PATCH format where each position communicates a specific type of change to anyone who depends on the software.
The specification was created by Tom Preston-Werner, co-founder of GitHub, as a direct response to what he called "dependency hell" — the increasingly tangled web of version constraints that made upgrading any library a gamble. Before semver, version numbers were largely meaningless to automated systems. A developer had no way to know whether upgrading from version 3.7 to 3.8 of a library would break their application or simply add a new feature. Semver solved this by encoding the answer into the version number itself.
The full specification is published at semver.org and is now in version 2.0.0 of its own standard. It has been universally adopted by the npm ecosystem (over 2 million packages), is the default for Rust's Cargo, Ruby's Bundler, and Go modules, and is the de facto standard for any software that other software depends on.
The Three Numbers Explained
Every semver version consists of three non-negative integers separated by dots: MAJOR.MINOR.PATCH. Each has a precise definition.
MAJOR (X.0.0) increments when you make incompatible API changes. This is the signal that tells consumers: "your existing code may break if you upgrade without changes." When the major version bumps, both minor and patch reset to zero.
- React 17 to 18: introduced concurrent rendering, changed the automatic batching behavior, and deprecated several legacy APIs. Upgrading required code changes for many applications.
- Python 2 to 3: fundamentally incompatible syntax changes (
printbecame a function, integer division changed, Unicode handling was overhauled). The migration took the Python ecosystem over a decade. - Angular 1.x to 2.0: a complete rewrite that shared almost nothing with the original framework. The most dramatic major version bump in front-end history.
MINOR (0.X.0) increments when you add functionality in a backwards-compatible manner. Existing code continues to work unchanged. New features are available but not required. When the minor version bumps, the patch resets to zero.
- Node.js 18.0 to 18.1: new API additions and improvements that don't change existing behavior.
- Adding a new endpoint to a REST API while keeping all existing endpoints unchanged.
- Adding a new optional parameter to a function with a sensible default value.
PATCH (0.0.X) increments for backwards-compatible bug fixes. No new functionality, no changed behavior — just corrections to existing functionality. Patches should always be safe to apply.
- Security patches that fix vulnerabilities without changing the API surface.
- Fixing a crash that occurred under specific edge-case conditions.
- Correcting a typo in an error message or fixing incorrect documentation.
Pre-Release Versions
Sometimes you need to publish a version that is not yet ready for production. Semver handles this with pre-release identifiers, appended to the version with a hyphen: 1.0.0-alpha.1, 1.0.0-beta.3, 1.0.0-rc.1.
The three conventional stages, in order of maturity:
- Alpha (
1.0.0-alpha.1): the earliest pre-release stage. Features are incomplete, bugs are expected, and the API may still change significantly. Alpha releases are primarily for internal testing and early adopters who understand the risks. - Beta (
1.0.0-beta.1): feature-complete but with known bugs. The API is mostly stable. Beta releases invite wider testing from the community to surface issues before the final release. - Release Candidate (
1.0.0-rc.1): believed to be ready for production. A release candidate is the final checkpoint — if no blocking issues are found, this becomes the release version. Multiple RCs may be published if issues are discovered.
Pre-release precedence follows a specific order: 1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0. A pre-release version always has lower precedence than the corresponding release version. This means npm install my-package@^1.0.0 will never install a pre-release unless explicitly requested.
Build Metadata
Build metadata is appended to a version with a plus sign: 1.0.0+build.123 or 1.0.0+sha.abc1234. Unlike pre-release identifiers, build metadata is completely ignored when determining version precedence. Two versions that differ only in build metadata are considered equal: 1.0.0+build.1 == 1.0.0+build.2.
Common use cases for build metadata include CI build numbers (1.0.0+build.456), git commit SHAs (1.0.0+sha.7a3f2c1), and build timestamps (1.0.0+20260430). The metadata provides traceability back to the exact source that produced the build without affecting version resolution.
Version Ranges and Constraints
Package managers use version ranges to specify which versions of a dependency are acceptable. Understanding range syntax is essential for managing dependencies effectively.
- Caret (
^1.4.2): allows changes that do not modify the left-most non-zero digit. For^1.4.2, this means>=1.4.2 <2.0.0. This is the npm default and the most commonly used range operator. It trusts that minor and patch updates are backwards-compatible. - Tilde (
~1.4.2): allows only patch-level changes.~1.4.2means>=1.4.2 <1.5.0. More conservative than caret — use this when you want bug fixes but not new features. - Exact (
1.4.2): pins to this exact version. The most restrictive option, sometimes necessary for critical dependencies where any change is risky. - Range (
>=1.4.2 <2.0.0): explicit minimum and maximum bounds. Useful when you need fine-grained control.
Different ecosystems handle ranges slightly differently. npm and Yarn use caret by default. Python's pip uses a different syntax entirely (~=1.4.2 for compatible release, ==1.4.* for wildcard). Rust's Cargo follows semver most strictly, using caret semantics by default and treating 0.x.y versions with special care (where ^0.1.2 means >=0.1.2 <0.2.0).
When to Bump Which Number
The decision of which number to increment can feel ambiguous, especially for borderline changes. Here is a decision framework:
The key principle: the version number communicates a promise to consumers. A patch promises nothing breaks and no new behavior is introduced. A minor promises nothing breaks but new capabilities exist. A major makes no backward-compatibility promises at all.
Common Versioning Mistakes
Even teams that adopt semver often get it wrong. Here are the most common pitfalls:
- Staying at 0.x.x forever. The semver spec explicitly states that anything can change at any time before 1.0.0. Some projects remain in "perpetual pre-release" for years, afraid to commit to API stability. If you have users depending on your software in production, ship 1.0.0.
- Bumping major for non-breaking changes. A major bump signals danger to consumers — they expect breaking changes and may delay upgrading. Using it for a big-but-compatible feature release confuses everyone. That exciting new feature is a minor bump, not a major one.
- Not bumping major for breaking changes. This is the worst sin in semver. Sneaking a breaking change into a minor or patch update violates the trust contract. Automated upgrades will pull the new version and break builds in production. This is how you lose the trust of your users permanently.
- Using versions for marketing. Chrome incrementing from 99 to 100 was a marketing event, not a breaking change. Firefox and Chrome use version numbers for branding, not semantic communication. This is fine for end-user software but inappropriate for libraries and APIs.
- Skipping versions. Going from 1.0.0 to 1.0.5 without publishing 1.0.1 through 1.0.4 is technically valid but confusing. Users will wonder what happened to the missing versions.
Semantic Versioning in the Real World
Different ecosystems have adopted semver to varying degrees, each with their own conventions and tooling.
npm is the largest semver ecosystem. With over 2 million packages, npm's entire dependency resolution system is built on semver assumptions. The package-lock.json file pins exact versions, while package.json uses ranges. The npm version major|minor|patch command automates bumping and git tagging.
Python/pip follows PEP 440, which is similar to semver but with important differences. Python uses post-releases (1.0.0.post1), developmental releases (1.0.0.dev1), and epoch numbers (1!1.0.0) that have no semver equivalent. The ~= compatible release operator is Python's closest analogue to npm's ^.
Go modules take a unique approach: major version 2 and above must change the import path. So github.com/user/pkg becomes github.com/user/pkg/v2. This means different major versions can coexist in the same project without conflicts — a clever solution to the diamond dependency problem.
Rust/Cargo is arguably the strictest semver implementation. Cargo automatically checks API compatibility and can detect breaking changes at compile time. The ecosystem takes semver seriously, and violating the contract is considered a bug in the library, not just bad practice.
Docker uses tag-based versioning that is often, but not always, semver. Official images typically use semver-compatible tags (node:18.1.0), but many projects use loose tags like latest, stable, or date-based versions.
Alternative Versioning Schemes
Semver is not the only approach to version numbering, and it is not always the right one.
- CalVer (calendar versioning): uses dates instead of arbitrary numbers. Ubuntu uses
YY.MM(24.04 means April 2024). pip itself uses CalVer. Best for projects where the release date matters more than API stability. - Marketing versions: Windows 10 to Windows 11, macOS Ventura to Sonoma. These serve branding, not dependency resolution. Internally, these products have separate build numbers for actual versioning.
- Git-based versioning: using commit hashes or
git describeoutput (likev1.4.2-14-g2414721). Common in continuous delivery pipelines where every commit is deployable. - Date-based: stamping releases with the build date (
2026.04.30). Simple and unambiguous, but communicates nothing about the nature of changes.
Semver works best for libraries, APIs, CLI tools, and any software with a public contract that other software depends on. For web applications, internal tools, and services with no external API consumers, the overhead of strict semver may not be worth it — and CalVer or continuous deployment with commit SHAs can be simpler and more honest.