# 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

Source: https://socket.dev/glossary/breaking-change

Last updated: 2026-11-01

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](https://socket.dev/glossary/semantic-versioning-semver) 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](https://socket.dev/blog/a-short-history-of-protestware) 1.4.1, [chalk 5.6.1](https://socket.dev/blog/npm-author-qix-compromised-in-major-supply-chain-attack), and [axios 1.14.1](https://socket.dev/blog/axios-npm-package-compromised) 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:

| 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](https://semver.org/) 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](https://www.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](https://docs.npmjs.com/cli/v11/using-npm/config#save-prefix) 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](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html) 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](https://socket.dev/glossary/transitive-dependency), because each installed `package.json` declares carets of its own. A committed [lockfile](https://socket.dev/glossary/lockfile) freezes the resolved tree, but only for installs that read it.

Many projects automate the bump. Under [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), a `!` after the type or a `BREAKING CHANGE:` footer tells release tooling to cut a new major:

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

- In Maven Central, Raemaekers, van Deursen, and Visser found in 2014 that ["around one third of all releases introduce at least one breaking change"](https://research.tudelft.nl/en/publications/semantic-versioning-versus-breaking-changes-a-study-of-the-maven-), at about the same rate for minor and major releases.
- A [2021 replication by Ochoa et al.](https://arxiv.org/abs/2110.07889v1) over 119,879 library upgrades found 83.4% of them comply with semver, and only 7.9% of 293,817 client projects were affected by a break.
- In Rust, a [September 2023 scan with cargo-semver-checks](https://predr.ag/blog/semver-violations-are-common-better-tooling-is-the-answer/) of 14,389 minor and patch releases from the top 1,000 crates found 464 violating releases (3.22%). 172 crates, 17.2%, had shipped at least one.

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](https://github.com/then/is-promise/issues/19) 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](https://socket.dev/blog/node-js-delivers-first-lts-with-require-esm-enabled), and the feature was later [backported to Node.js 20](https://socket.dev/blog/require-esm-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.

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

| 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](https://go.dev/ref/mod) 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](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html), 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](https://socket.dev/glossary/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](https://socket.dev/blog/announcing-socket-fix-2-0) 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](https://socket.dev/glossary/automated-dependency-updates) 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](https://socket.dev/glossary/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](https://socket.dev/blog/certified-patches), 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.

> Four caret ranges. Each accepts the attacker's patch release and rejects the next honest major or 0.x minor release.

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:

- colors was maintainer sabotage, a case of [protestware](https://socket.dev/glossary/protestware). [GitHub's advisory](https://github.com/advisories/GHSA-5rqg-jm4f-cqx7) rates it high severity: an infinite loop that printed garbage to the console. colors had [over 20 million weekly downloads](https://socket.dev/blog/a-short-history-of-protestware) at the time.
- chalk 5.6.1 and [debug](https://socket.dev/blog/npm-author-qix-compromised-in-major-supply-chain-attack) 4.4.2 came from a phished maintainer, a [maintainer account takeover](https://socket.dev/glossary/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](https://socket.dev/blog/axios-npm-package-compromised), 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](https://socket.dev/blog/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](https://socket.dev/glossary/dependency-cooldown) refuses releases younger than a threshold. As of May 2026, [pnpm 11](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) 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](https://socket.dev/blog/rust-cargo-cooldowns), 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](https://socket.dev/blog/axios-npm-package-compromised), 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](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) stopped running dependency lifecycle scripts by default in January 2025, and [npm v12](https://socket.dev/blog/npm-12) 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.
