Semantic Versioning: The Rules, the Edge Cases, and Why People Get It Wrong
SemVer looks simple — major.minor.patch. The hard part is judging what counts as a breaking change. Learn the rules, the dependency-resolution implications, and the practical patterns that make versioning useful.
SemVer is a simple-looking standard that quietly shapes the entire JavaScript ecosystem (and many others). Get it right and your dependents thank you with confidence. Get it wrong and you ship a major-version-bumping nightmare or a deceptively-stable patch update that breaks production.
The basic rules
Versions are MAJOR.MINOR.PATCH. When you release changes:
- MAJOR (X.0.0): incompatible API changes. Existing code using your library may break.
- MINOR (0.X.0): new features added in a backwards-compatible way. Existing code continues to work.
- PATCH (0.0.X): bug fixes that don't change the API. Strict refactors and internal improvements.
What counts as a breaking change?
This is where most disagreements happen. Indisputably breaking:
- Removing a function, method, or class.
- Renaming a public API.
- Changing a function's parameter list (positional).
- Changing a return type or shape.
- Throwing a new exception type that callers may not handle.
- Removing a configuration option.
Debatably breaking (different communities disagree):
- Stricter input validation. Rejecting inputs that previously succeeded silently. Some say breaking; others say bug fix.
- Performance regression. Same API, slower behavior. SemVer doesn't address performance, but real users break.
- Bumping the minimum supported runtime. Requiring Node 18+ when 16 was supported is technically breaking for some users.
- Changes to undocumented behavior. If users relied on it, fixing it breaks them. Hyrum's Law: with sufficient users, every observable behavior is depended upon.
The Hyrum's Law tension
Versioning of pre-1.0 software
Versions 0.x.y are special. The SemVer spec says anything goes. In practice:
- 0.MINOR may signal breaking changes.
- 0.MINOR.PATCH still implies bug fixes only.
- Reaching 1.0 commits to API stability.
Many libraries stay in 0.x for years to avoid the "commitment to 1.0" — Node.js's npm package landscape has hundreds of thousands of packages stuck at 0.x. This is fine; it just means consumers should pin minor versions (~0.5.0, not ^0.5.0) for safety.
Pre-release and build metadata
1.0.0-alpha.1— pre-release. Sorted before 1.0.0.1.0.0-beta.2,1.0.0-rc.1— same idea.1.0.0+build.123— build metadata, ignored for ordering.
Pre-release versions are not installed by default by package managers — you must opt in (npm install foo@beta).
Range operators (npm-style)
npm and Yarn use range operators in package.json:
^1.2.3(caret): compatible with 1.2.3 — accepts patches and minors but not majors. So 1.2.3 to < 2.0.0.~1.2.3(tilde): reasonably close — accepts patches but not minors. So 1.2.3 to < 1.3.0.1.2.3(exact): only this version.1.x,1: any 1.x.x.*orlatest: any version. Don't.
Default for npm install: caret (^). For most production code, this is the right balance. For lockfile-only deployments, exact pinning is the safest.
The lockfile
package-lock.json (npm), yarn.lock, pnpm-lock.yaml record the exact versions installed. They make builds reproducible regardless of what new versions get published between yesterday's install and today's.
Always commit the lockfile. CI should use npm ci (or equivalent) which installs exactly what the lockfile specifies — no version resolution, no surprises.
Calendar versioning (CalVer): the alternative
Some projects use date-based versions instead: 2024.10.27. Examples: Ubuntu (24.04), JetBrains IDEs, pip.
Pros:
- No semantic judgment required.
- Users can immediately tell how old a version is.
Cons:
- No signal about breaking changes.
- Doesn't work well with package-manager range operators.
CalVer fits applications and platforms; SemVer fits libraries with a stable public API.
Practical versioning patterns
For library authors
- Document your API as the contract. Anything not documented is "internal" and can change in patches.
- Deprecate before removing. Mark APIs deprecated in a minor release; remove in a major release.
- Provide a migration guide for major releases. List every breaking change with before/after examples.
- Use changelogs.
CHANGELOG.mdin Keep-A-Changelog format.
For library consumers
- Use caret (
^) ranges and trust the maintainer's SemVer for established libraries. - Pin exact versions for high-stakes dependencies (auth libraries, security-sensitive code).
- Use Renovate or Dependabot to automate updates with PR-level review.
- Run a compatibility test suite that catches breaking changes you missed.
The 0.0.x trap
Some projects publish dozens of versions at 0.0.x. This is a SemVer red flag:
- 0.0.x means everything is unstable.
- npm caret on 0.0.x is treated as exact (no auto-updates).
- Users have no signal about feature additions vs bug fixes.
Move to 0.1.0 once you have a working API. Move to 1.0.0 once it's stable enough for production use.
Common SemVer mistakes
- Patch releases that change behavior. "Just a bug fix" breaks downstream code.
- Major bumps for non-breaking changes. Inflated major version numbers (Chrome 130, Firefox 120) are CalVer in disguise.
- Skipping deprecation. Going from feature → removed in one release breaks every consumer.
- Re-using version numbers. Republishing the same version with different content. Lockfiles trust version numbers — never reuse.
- No changelog. Users can't evaluate updates.
npm outdated+ no changelog = users skip your updates.
Key Takeaways
- MAJOR.MINOR.PATCH = breaking.feature.fix. The judgment call is what counts as breaking.
- Hyrum's Law: every observable behavior eventually has dependents. Strict SemVer means treating any change as potentially breaking.
- 0.x.y versions are explicitly unstable. Stay there until you commit to API stability; then move to 1.0.0.
- Caret (^) accepts patches and minors; tilde (~) accepts only patches. Pin exact for high-stakes dependencies.
- Always commit lockfiles. Always document changelogs. Always deprecate before removing.