Skip to main content
npm

Semantic versioning and package.json: versions only work if you mean them

Semantic versioning gives consumers an upgrade signal: breaking API changes are major, compatible features are minor, and compatible fixes are patch. package.json ranges decide how much trust automation gets.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 3 min read

Nobody touched the code. The pull request was a one-line copy fix. But CI is red on a branch that was green yesterday, and the diff that broke it is in pnpm-lock.yaml — a transitive dependency went from 2.3.4 to 2.4.0 overnight because three levels down, something declared ^2.3.0. The "minor" bump removed an export. That's semantic versioning failing exactly as designed: the number said safe, the range trusted it, and the promise was a lie.

The system only works if authors mean the numbers. MAJOR.MINOR.PATCH encodes a contract — increment major for an incompatible public-API change, minor for a backwards-compatible feature, patch for a backwards-compatible fix — and every ^ range in every package.json downstream is betting the publisher honored it.

ChangeVersion bump
Fix incorrect output, no API changePatch: 1.4.2 → 1.4.3
Add an optional, backwards-compatible capabilityMinor: 1.4.2 → 1.5.0
Remove or change existing public behaviorMajor: 1.4.2 → 2.0.0

The mistakes that turn versions into roulette

Calling a breaking change "minor" because it's small

The size of the change is irrelevant; compatibility is the only axis. Removing one export, renaming one option, tightening one validation that used to pass — all major, no matter how few lines. "It's a tiny break" is the single most common way a ^ range detonates a downstream build, because the whole point of the range is that it auto-installs your minors.

Assuming 0.x behaves like 1.x

Before 1.0.0, anything can change at any time — semver explicitly says the initial-development API is unstable. And the caret rule is different down here: ^0.2.3 does not mean <1.0.0. It resolves to >=0.2.3 <0.3.0, treating the minor as the breaking position. People read ^0.2.3 as "any 0.x" and get surprised when 0.3.0 never installs.

Shipping a lockfile-free application

For a library, ranges are correct — you don't want to pin your consumers. For a deployed application, ranges without a committed lockfile mean your reproducible build depends on what the registry served this morning. Commit package-lock.json / pnpm-lock.yaml so the same commit resolves to the same bytes in six months.

Burning a major on a rename you could have deprecated

Deprecation is usually the cheaper mitigation. Ship the replacement, mark the old path deprecated with a runtime or type-level warning, and remove it in the next major — don't yank it in a patch and surprise everyone who trusted the number.

^ vs ~: what you're actually allowing

The two range operators get muddled constantly, and the difference is which position they pin:

RangeExpands toAllowsBlocks
^1.2.3>=1.2.3 <2.0.0New minors and patches (1.3.0, 1.9.9)Majors (2.0.0)
~1.2.3>=1.2.3 <1.3.0Patches only (1.2.4, 1.2.9)Minors (1.3.0)
^0.2.3>=0.2.3 <0.3.0Patches only (0.2.x)0.3.0

^ (the npm default) trusts minors; ~ trusts only patches. Reach for ~ when you've been burned by a dependency's loose interpretation of "minor" and want new features to be a deliberate npm update, not a silent one.

Versions are a communication channel, not a build number. The publisher's job is to make the number match the risk; the consumer's job is to pick a range that matches how much they trust it — and to commit a lockfile so trust isn't required on every install.

Cover photo by Pixabay on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

npmSemantic VersioningJavaScript

Related articles

All articles