Back to glossary

Breaking Change

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.

What is a breaking change?

#

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:

Kindnpm exampleWhere it surfaces
Build or type breakAn export is removed, a function is renamed, a TypeScript type is narrowedCompile or type-check, if the consumer has one
Load breakThe package goes ESM-only, an exports map hides a deep import path, engines.node is raisedThe first require() or import, often in a code path tests skip
Runtime breakA function throws where it used to return undefined, a default option flipsWhen that code path executes
Behavioral breakSame signature, different result: sort order, rounding, error message textPossibly 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.

How does semantic versioning signal a breaking change?

#

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:

Bash
$ 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.3

On 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:

Plain text
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.

How common semver violations are

#

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."

Whether a release breaks depends on the runtime

#

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:

Bash
$ 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 function

On 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.0Node.js 18.20.8Node.js 22.23.4
import chalk from 'chalk'WorksWorks
require('chalk').red(...)ERR_REQUIRE_ESM at loadTypeError at call
require('chalk').default.red(...)ERR_REQUIRE_ESM at loadWorks

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.

How package managers encode breaking changes

#

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.

EcosystemConstraint written by defaultWhat updates automaticallyHow a new major appearsTypical failure
npm^1.2.3Minor and patch (patch only on 0.x)Same package name; several majors can coexist in node_modulesA bad minor or patch arrives on a fresh resolve
Cargo"1.2.3", which means caretMinor and patch (patch only on 0.x)Same crate name; incompatible majors can coexist in one buildBreaks hidden inside compatible ranges
Go modulesAn exact minimum in go.modNothing. Minimal version selection picks the highest required minimum, never a newer oneA new module path ending in /v2Breaks arrive only when someone raises a minimum
pip and uvpip records nothing; uv add six writes six>=1.17.0, with no upper boundEverything, including new majors, on re-resolveSame name; one version per environmentSilent major upgrades or unsolvable conflicts
MavenAn exact version, treated as a soft preferenceNothing floatsSame coordinates; one version per classpathNearest-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.

Why do teams avoid updating dependencies?

#

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.

Does semantic versioning protect against supply chain attacks?

#

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.

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:

PackageMalicious versionPublished (UTC)Prior clean versionRange that accepted itRejected by that range
colors1.4.1, then 1.4.2Jan 8, 2022 19:22; Jan 9, 2022 19:351.4.0 (Sept 2019)^1.4.02.0.0
chalk5.6.1Sept 8, 2025 13:135.6.0 (Aug 17, 2025)^5.6.06.0.0
axios1.14.1Mar 31, 2026 00:211.14.0 (Mar 27, 2026)^1.14.02.0.0
axios0.30.4Mar 31, 2026 01:000.30.3 (Feb 18, 2026)^0.30.00.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:

  • colors was maintainer sabotage, a case of protestware. GitHub's advisory rates it high severity: an infinite loop that printed garbage to the console. colors had over 20 million weekly downloads at the time.
  • chalk 5.6.1 and debug 4.4.2 came from a phished maintainer, a maintainer account takeover. The payload swapped cryptocurrency recipient addresses in browsers, in packages that together see 2 to 3 billion weekly downloads.
  • Both axios lines were poisoned within 39 minutes of each other, through a new dependency, plain-crypto-js, in a package with about 100 million weekly downloads.

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:

  • Lockfiles. A committed lockfile plus 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.
  • Cooldowns. A dependency cooldown refuses releases younger than a threshold. As of May 2026, pnpm 11 sets 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.
  • Content inspection. Inspecting the release itself works regardless of the version number. Our scanner flagged plain-crypto-js 4.2.1 as malware 6 minutes and 29 seconds after it reached npm, from the package contents rather than an advisory feed. Detection still depends on the payload looking like something, and an install-time block only helps clients that consult it.

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.

What a version number cannot tell you

#

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.