What a caret range lets through
Each range was a normal default in package.json. The shaded side is what installs automatically.
A breaking change is an update to a library, API, or runtime that makes previously working dependent code fail to build, crash, or behave differently.
Also known as Breaking changes, Backward-incompatible change, Backwards incompatible change, Semver-major change, Incompatible API change
A breaking change, also called a backward-incompatible or semver-major change, is a modification to a library, API, or runtime that makes previously working dependent code fail to build, crash, or behave differently, without the consumer changing anything. Semantic versioning reserves major version bumps for breaking changes, and package managers trust that signal to decide which updates install automatically.
That trust is the weak point. A version number is a claim the publisher types into a manifest, and no major registry verifies it. Line up three of the largest npm incidents of recent years and the pattern repeats: colors 1.4.1, chalk 5.6.1, and axios 1.14.1 were all published as patch releases, so ordinary caret ranges installed them on the next fresh resolve. Meanwhile, teams burned by real breaking changes stop updating and sit on known vulnerabilities. Our position: treat the version number as untrusted metadata. Decide whether to take an update based on what the new code does, not on which digit moved.
A breaking change is any change after which code that worked against the old version stops working against the new one. "Stops working" covers four failure modes, and each one surfaces at a different point in your pipeline:
| Kind | npm example | Where it surfaces |
|---|---|---|
| Build or type break | An export is removed, a function is renamed, a TypeScript type is narrowed | Compile or type-check, if the consumer has one |
| Load break | The package goes ESM-only, an exports map hides a deep import path, engines.node is raised | The first require() or import, often in a code path tests skip |
| Runtime break | A function throws where it used to return undefined, a default option flips | When that code path executes |
| Behavioral break | Same signature, different result: sort order, rounding, error message text | Possibly never in tests; in production data |
Semantic versioning defines breaking relative to a public API, which the Semantic Versioning 2.0.0 spec says software "MUST declare". Almost no npm package declares one precisely. In practice the public API is everything a consumer can observe, which is the point of Hyrum's Law. Hyrum Wright, then a software engineer at Google, put it this way: "all observable behaviors of your system will be depended on by somebody" (hyrumslaw.com). A maintainer who rewords an error message has shipped a breaking change to the one consumer who matched on that string.
So a breaking change is a relationship between a release and its consumers, not a property of the diff alone. The publisher decides what the version number says. The consumer's code decides whether it breaks.
Semantic versioning signals a breaking change by incrementing MAJOR in MAJOR.MINOR.PATCH. The spec says the major version "MUST be incremented if any backward incompatible changes are introduced to the public API." Below 1.0.0 the contract is suspended: "Anything MAY change at any time."
Package managers turn that convention into an install policy. npm install writes a caret range into package.json by default, because npm's save-prefix setting defaults to ^. A caret accepts any later version that keeps the left-most non-zero digit. We ran npm's own semver package against the exact versions that matter later in this article:
$ npx semver -r '^1.14.0' 1.14.0 1.14.1 1.15.0 2.0.0
1.14.0
1.14.1
1.15.0
$ npx semver -r '^0.30.0' 0.30.3 0.30.4 0.31.0
0.30.3
0.30.4
$ npx semver -r '^0.0.3' 0.0.3 0.0.4
0.0.3On 1.x a caret floats across minors and patches. On 0.x it floats across patches only, because a 0.x minor bump is treated as breaking. On 0.0.x it pins. Cargo applies the same rule to a bare "1.2.3" requirement: versions are compatible "if their left-most non-zero major/minor/patch component is the same." A tilde range, ~1.14.0, floats patches only.
Every package in your tree carries its own ranges. You can pin your direct dependencies exactly and still float hundreds of transitive dependencies, because each installed package.json declares carets of its own. A committed lockfile freezes the resolved tree, but only for installs that read it.
Many projects automate the bump. Under Conventional Commits, a ! after the type or a BREAKING CHANGE: footer tells release tooling to cut a new major:
feat(api)!: drop CommonJS entry point
BREAKING CHANGE: the package is now ESM-only and requires
Node.js >=20. Replace require('pkg') with import.Automation makes the bump mechanical. It does not make it correct. The tool only knows what the commit author admitted.
Breaking changes in minor and patch releases show up in every ecosystem that has been measured. The numbers are small per release and large per project:
The Rust data is the most useful because the checks are mechanical. The most common violations were removing an inherent method (791 instances) and adding a variant to an exhaustive enum (382). Neither looks dangerous in review. Predrag Gruevski, who wrote cargo-semver-checks and ran the study, concluded: "semver accidents are common. They happen even in the most carefully-developed projects run by the most experienced maintainers."
The two readings of this data are both correct. Most compatible-range updates are safe, which is why floating minors and patches is a reasonable default for receiving fixes. But the cost of the exceptions lands on whoever happens to use the changed surface, and nobody can tell in advance whether that is them.
The npm example many engineers remember is is-promise. On April 25, 2020 at 15:03 UTC, is-promise 2.2.0 shipped with "type": "module" and an exports map, its first release since 2.1.0 in September 2015. On Node.js versions that honored the new fields, require('is-promise') now threw ERR_REQUIRE_ESM, as the bug report shows. Version 2.2.2 restored compatibility at 18:08 UTC, about three hours later, according to the registry's publish timestamps. In that thread, Node.js contributor Jordan Harband stated the rule the release had missed: "Adding exports is intended to be a breaking change."
chalk 5.0.0, published November 26, 2021, shows how a load break depends on who loads it. The registry manifests (npm view chalk@4.1.2 against npm view chalk@5.0.0) show the diff: 5.0.0 adds "type": "module" and an exports field, removes main, and raises engines.node from >=10 to ^12.17.0 || ^14.13 || >=16.0.0. The coloring functions barely changed. How the package loads changed completely, and the failure depends on the Node.js version:
$ npm i chalk@5.0.0
$ npx node@18 -e "require('chalk')"
Error [ERR_REQUIRE_ESM]: require() of ES Module .../node_modules/chalk/source/index.js from .../[eval] not supported.
$ node -v
v22.23.4
$ node -e "require('chalk').red('x')"
TypeError: require(...).red is not a functionOn Node.js 18 the break is loud and happens at load. Node.js 22.12 was the first LTS with require(esm) enabled by default, and the feature was later backported to Node.js 20. There, the same require() succeeds and returns the module namespace object. The default export sits under .default, so the old call site fails later, at the first call, with a TypeError. We ran each combination:
| Consumer code with chalk 5.0.0 | Node.js 18.20.8 | Node.js 22.23.4 |
|---|---|---|
| import chalk from 'chalk' | Works | Works |
| require('chalk').red(...) | ERR_REQUIRE_ESM at load | TypeError at call |
| require('chalk').default.red(...) | ERR_REQUIRE_ESM at load | Works |
Same release, same consumer code, two different failure modes. A test suite that runs on one Node.js version says little about the other. This is also why raising the minimum supported runtime is a breaking change even when no function signature moves.
Every ecosystem answers the same question differently: when a dependency ships a new version, who decides whether you get it? The defaults matter more than the spec, because the defaults are what most projects run.
| Ecosystem | Constraint written by default | What updates automatically | How a new major appears | Typical failure |
|---|---|---|---|---|
| npm | ^1.2.3 | Minor and patch (patch only on 0.x) | Same package name; several majors can coexist in node_modules | A bad minor or patch arrives on a fresh resolve |
| Cargo | "1.2.3", which means caret | Minor and patch (patch only on 0.x) | Same crate name; incompatible majors can coexist in one build | Breaks hidden inside compatible ranges |
| Go modules | An exact minimum in go.mod | Nothing. Minimal version selection picks the highest required minimum, never a newer one | A new module path ending in /v2 | Breaks arrive only when someone raises a minimum |
| pip and uv | pip records nothing; uv add six writes six>=1.17.0, with no upper bound | Everything, including new majors, on re-resolve | Same name; one version per environment | Silent major upgrades or unsolvable conflicts |
| Maven | An exact version, treated as a soft preference | Nothing floats | Same coordinates; one version per classpath | Nearest-definition mediation picks an older version than a library needs, and it fails at runtime |
Go is the only design here where a breaking change cannot arrive under an old import path by construction. Its import compatibility rule states: "If an old package and a new package have the same import path, the new package must be backwards compatible with the old package." Starting at v2, the module path must carry the major suffix, so example.com/mod and example.com/mod/v2 are different modules. The cost is real. A major upgrade means editing every import statement, and a maintainer who breaks compatibility within v1 still breaks its consumers, because the rule is a convention the toolchain cannot verify.
Maven's failure is the mirror image. Under nearest-definition mediation, if your project reaches D 2.0 through a deep path and D 1.0 through a shallow one, Maven builds with D 1.0. The library that needed 2.0 compiled against 2.0 and can throw NoSuchMethodError in production. That is dependency hell caused by an unannounced downgrade, not an upgrade.
Our verdict: caret ranges are a deliberate ecosystem trade-off that lets libraries share compatible versions, and they are the right thing for a library to declare. An application should pin its direct dependencies exactly, commit its lockfile, and review what each update changed before taking it. Python's open lower bounds are the weakest default; one uv lock --upgrade can cross a major with no signal. Go's model is the one other ecosystems should study: it turns "is this breaking?" from a judgment call buried in a version number into a different name.
Teams avoid updating dependencies because the cost of an update is unbounded and unknown in advance, while the cost of not updating is deferred. Each skipped release makes the next jump bigger. Eventually an upgrade crosses several breaking changes at once, which is exactly the upgrade nobody wants to own.
Security fixes do not respect your version. Our Socket Fix 2.0 write-up walks through a real case. GHSA-f7f6-9jq7-3rqj in estree-util-value-to-estree affects versions below 3.3.3. The project's direct dependency, remark-reading-time@2.0.1, requires ^1.3.0, so no compatible fix exists. The working repair is to upgrade remark-reading-time to 2.0.2, whose constraint becomes ^3.3.3. A one-line advisory became a two-major jump in a package the team never chose. Automated dependency update bots open pull requests like that one, and teams close them when CI goes red. Repeat that for a few years and the tree fills with end-of-life dependencies.
Socket Certified Patches takes the other route. It applies a small patch, often a line or two, to the version you already run, stores it in a .socket/ directory in the repository, and applies it during builds, so the fix ships without a version change. The trade-off is explicit: you are running code upstream never published, the patch is only as trustworthy as its review and tests, and at launch in November 2025 it covered npm only, in closed beta. It buys time for the real upgrade. It does not replace it.
Semantic versioning does not protect against supply chain attacks; it delivers them. A caret range is a standing order to install whatever the publisher labels compatible. An attacker with publish rights does not need to break anything. They only need to choose the right digit.
What a caret range lets through
Each range was a normal default in package.json. The shaded side is what installs automatically.
The figure shows four caret ranges and what each accepted. Every malicious release was a patch bump that its range let through, and every honest breaking release would have been blocked:
| Package | Malicious version | Published (UTC) | Prior clean version | Range that accepted it | Rejected by that range |
|---|---|---|---|---|---|
| colors | 1.4.1, then 1.4.2 | Jan 8, 2022 19:22; Jan 9, 2022 19:35 | 1.4.0 (Sept 2019) | ^1.4.0 | 2.0.0 |
| chalk | 5.6.1 | Sept 8, 2025 13:13 | 5.6.0 (Aug 17, 2025) | ^5.6.0 | 6.0.0 |
| axios | 1.14.1 | Mar 31, 2026 00:21 | 1.14.0 (Mar 27, 2026) | ^1.14.0 | 2.0.0 |
| axios | 0.30.4 | Mar 31, 2026 01:00 | 0.30.3 (Feb 18, 2026) | ^0.30.0 | 0.31.0 |
The publish times come from the npm registry's time field, which still lists the removed versions. The three incidents had three different causes:
Semver did exactly what it was designed to do in all three. It assumes an honest publisher, and none of these publishes came from one.
Defenses exist, and each one has a gap:
npm ci keeps existing installs from drifting. As our analysis of the hidden blast radius of the axios compromise puts it, lockfiles "make existing installs deterministic. They do not make new installs safe." A new dependency, a regenerated lockfile, or an npx run resolves against the registry as it is right now.minimumReleaseAge to 1440 minutes, one day, by default. The bet is that malicious releases get caught and pulled before the window closes. The cost: as we noted for Cargo's cooldown, legitimate fixes wait too, and a patient attacker can simply outlast the window.The strongest fixes of the last two years were themselves breaking changes. pnpm 10 stopped running dependency lifecycle scripts by default in January 2025, and npm v12 followed in July 2026. On npm v12, a skipped native build such as sharp now fails later, at runtime. We think that break was overdue. Default-on install scripts gave every patch release a code execution hook on every developer machine, and no version-number convention could have contained that.
A version number compresses a behavioral diff into one digit chosen by the publisher. For an honest maintainer, that digit is a best guess the Rust data shows is wrong a few percent of the time. For an attacker, it is a delivery mechanism. Tools like cargo-semver-checks can compute API breaks mechanically, but no major registry requires such a check at publish time, and none of them would flag a changed default, a new network call, or an obfuscated payload.
We think the fix is to make the release diff, not the version number, the thing consumers review: new install scripts, new dependencies, new network and filesystem access, changed exports, and a raised engines floor, surfaced at update time for every version a range would accept. Until that is normal, read ^1.14.0 literally: whatever the account holder publishes next, as long as it starts with 1.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.