Back to blog
Jun 2026·4 min readAI-generated

A Practical Guide to Semantic Versioning

#devops#npm#best-practices

^2.4.1 in a package.json looks precise, but that caret changes what it means depending on whether the maintainer on the other end actually followed the rules it's built on. Semantic Versioning (semver) is a promise encoded in three numbers — and it only works as a promise if both the publisher and the consumer understand exactly what it commits to.

The three numbers

A semver version is MAJOR.MINOR.PATCH, and each position means something specific:

  • MAJOR — incremented for breaking changes. Anything that could make existing correct code stop working: a removed function, a renamed parameter, a changed return type, a new required config field.
  • MINOR — incremented for new, backward-compatible functionality. New optional parameters, new exported functions, new config options with sane defaults.
  • PATCH — incremented for backward-compatible bug fixes. No new functionality, no behavior change except "the thing that was broken now works as documented."

1.4.2 → 1.5.0 means "new stuff, nothing should break." 1.4.2 → 2.0.0 means "read the changelog before upgrading." That distinction is the entire value of semver — it lets tooling and humans make upgrade decisions without reading every commit.

What counts as "breaking" is stricter than it feels

The instinct is to reserve major bumps for dramatic rewrites, but semver's definition is about any incompatible API change, however small:

// v1.2.0
function formatPrice(amountCents) {
  return `$${(amountCents / 100).toFixed(2)}`;
}

// v1.3.0 — adding an optional param is fine, existing callers are unaffected
function formatPrice(amountCents, currency = 'USD') {
  return `${currency} ${(amountCents / 100).toFixed(2)}`;
}

// v2.0.0 — changing the parameter's meaning breaks every existing caller
function formatPrice(amountDollars, currency = 'USD') {
  return `${currency} ${amountDollars.toFixed(2)}`;
}

That last change is a one-line diff and looks minor, but every caller passing cents now silently gets wrong output instead of an error — which makes it worse than a loud breaking change, not better. It's a major bump regardless of how small the code change looks.

Removing a publicly exported function, changing a function from synchronous to returning a Promise, narrowing accepted input types, changing error messages that callers pattern-match on — these are all breaking changes even though none of them feel like "a big rewrite."

Version ranges: what ^ and ~ actually promise

npm install writes range operators into package.json, and they only make sense in light of what semver guarantees:

{
  "dependencies": {
    "lodash": "^4.17.21",
    "express": "~4.18.2",
    "left-pad": "1.3.0"
  }
}
  • ^4.17.21 (caret) — accepts any version that doesn't change the leftmost non-zero number. So 4.17.21 up to (not including) 5.0.0 — any minor or patch update, no majors. This is npm's default and the right choice for most dependencies: you get bug fixes and new features automatically, breaking changes require an explicit action.
  • ~4.18.2 (tilde) — accepts patch-level updates only: 4.18.2 up to (not including) 4.19.0. More conservative — you opt into new features manually.
  • 1.3.0 (exact) — no updates at all without editing package.json. Appropriate when a dependency has a history of breaking semver, or when you need fully reproducible builds independent of package-lock.json.

The caret's "leftmost non-zero" rule has a sharp edge for pre-1.0 packages: ^0.4.2 only accepts patch updates (0.4.x), not minor ones, because before 1.0.0 the second number is treated as the breaking-change position. Semver explicitly documents 0.x.y as "anything might change at any time" — which is exactly why getting a library to a stable 1.0.0 matters.

Publishing your own package: making the promise correctly

If you maintain a package, the discipline is on you, not your users:

  1. Decide the bump before you decide the release. Look at the diff since the last release and categorize it: any breaking change anywhere → major. No breaking changes but new features → minor. Otherwise → patch. Don't pick the version number first and rationalize the category to fit.
  2. Use npm version instead of hand-editing. It updates package.json, creates a git tag, and enforces valid semver strings.
npm version patch   # 1.4.2 -> 1.4.3
npm version minor    # 1.4.2 -> 1.5.0
npm version major    # 1.4.2 -> 2.0.0
  1. Write a changelog entry per release, not just a commit log. "What broke, what's new, what's fixed" — three sentences is enough, but it's the difference between a consumer confidently upgrading and a consumer pinning your package forever out of fear.
  2. Deprecate before you remove. Mark the old API as deprecated (in docs, in JSDoc, with a runtime warning) for at least one minor release before removing it in a major — gives consumers a migration window instead of a surprise.

Pre-release and build metadata

Semver also defines suffixes for versions before general availability:

2.0.0-alpha.1
2.0.0-beta.3
2.0.0-rc.1
2.0.0

These sort before the plain version they precede (2.0.0-rc.1 < 2.0.0), and package managers won't install them via a normal range unless explicitly requested — npm install mypackage@next or an exact pre-release version. This is how you ship an in-progress major version for early testing without every ^1.x.x consumer accidentally pulling in unfinished, breaking work.

Takeaway

Semver is a contract, not a formality: MAJOR promises "read this before upgrading," MINOR promises "safe to take automatically, here's something new," PATCH promises "nothing changed except a bug got fixed." The version range operators in package.json only protect you if the packages you depend on actually honor that contract — and your own published packages only protect your consumers if you do the same.

NewerDebugging Memory Leaks in Node.jsOlderBig O Notation, Explained with Real Code