API changes

Breaking changes: what counts and how to ship one

9 min read updated

A breaking change is a change that a correctly written caller could not have survived. The definition matters because most arguments about whether something “counts” are really arguments about who was holding it wrong. If a caller followed your documentation and your change made their code stop working, the change was breaking. What you intended has nothing to do with it.

That is the whole test. The rest of this article is what falls out of it: what fails it, what passes it, how to catch a failure before it merges, and what to do once you know you are shipping one.

What counts as a breaking change?

Apply the test to the caller, not to the diff. A change is breaking when a caller who relied only on documented behaviour has to change their code, their configuration or their data to keep working. Removing a field, renaming an endpoint, tightening validation, changing a default and changing the type of a value all qualify. Adding an optional field is not. Fixing a bug usually is not, with one important exception below.

ChangeBreaking?Why
Remove or rename a field, endpoint, flag or optionYesCorrect callers reference it
Add an optional field or a new endpointNoExisting calls are unchanged
Make an optional input requiredYesCalls that omitted it now fail
Tighten validation you previously acceptedYesInputs that worked are now rejected
Change a default valueYesCallers who did not set it get new behaviour
Change a type (string to number, single to array)YesParsers written to the documented type fail
Reorder an object’s keysNoUnless you documented the order
Fix a bug callers depended onYes, in practiceSee the section on accidental contracts
Raise a rate limit or a size capNoNothing that worked stops working
Lower a rate limit or a size capYesTraffic that was fine is now throttled
Change an error message’s wordingDependsBreaking if you documented it or callers match on it

What is not a breaking change?

A change is non-breaking when every call that worked before still works, unchanged, and still means the same thing. Adding a new endpoint, adding an optional request parameter, adding a field to a response, making a required input optional, raising a limit and improving an error message nobody matches on all pass the test. These additive changes can ship in a minor release with an ordinary changelog entry.

Additive changes still break callers in three situations. A client whose deserializer rejects unknown fields fails on the first new response field, so document early that callers must ignore fields they do not recognise. A new enum value breaks any caller with an exhaustive switch (more on that below). And a response that grows can push a caller past a size limit, a timeout or a column width they never had to think about.

Four rows of the table deserve a closer look, because they are where the disagreements happen.

The four breaking changes teams miss

Accidental contracts. If your API has returned the same undocumented field for three years, a caller has built on it. Hyrum’s Law is the short version: with enough users, every observable behaviour of your system will be depended on by somebody. This is why “it was a bug fix” is not a defence. The fix may be correct and still be breaking. Ship it as one.

Behavioural changes with no schema change. The field is still there, the type is the same, and the value now means something different. A status that used to be active or inactive and now also returns suspended breaks every caller with an exhaustive switch. A timestamp that moves from local time to UTC breaks everyone who did not read the docs twice. Nothing in a diff of the OpenAPI file shows these.

Tightened validation. You start rejecting emails without a TLD, or trailing whitespace, or names longer than 80 characters. Every caller who was sending exactly that is now getting a 400 for a request that worked last week. Validation changes are the most common one shipped as a “hardening” fix.

Changed defaults. Nobody who set the value explicitly notices. Everybody who did not, which is most callers, gets new behaviour without changing a line. A changed default breaks the majority of your users specifically because they never saw the setting.

How do you detect a breaking change before it ships?

Compare the contract on the pull request against the contract on the main branch, in CI, and fail the build on a breaking difference. Schema diff tools exist for most interface formats, and each knows the breaking rules of its own format:

InterfaceToolWhat it compares
REST (OpenAPI)oasdiffTwo OpenAPI specs, with a breaking-changes report
gRPC (Protobuf)buf breaking.proto files, at wire or source level
GraphQLGraphQL InspectorTwo schemas, flagging breaking and dangerous changes
Rust cratescargo-semver-checksThe public API against the last published version
TypeScript packagesAPI ExtractorA committed report of the package’s public API

These tools catch removed fields, renamed operations and changed types reliably. They cannot see the first two of the four kinds above, an accidental contract or a behavioural change, because neither shows up in a schema. Use the tool to stop the obvious ones and the review question “could a correct caller notice this?” for the rest. The same CI job is a natural place to require a changelog entry, as described in enforcing changelog entries in CI, and gRPC and Protobuf API changes goes through the wire-level cases.

How do you mark a breaking change in a commit?

With Conventional Commits, a breaking change is marked by a ! before the colon (feat(api)!: remove the legacy export endpoint) or by a footer that starts with BREAKING CHANGE: followed by a description. Either one maps to a major version. Write the footer as the first draft of the changelog entry, naming who is affected and what they must do. Conventional commits and the changelog covers how far the convention gets you.

The same rule holds for libraries. A removed public function, a narrowed parameter type or a changed return value is a major version under semantic versioning. Libraries do not always follow it: a study of 119,879 Maven Central upgrades found 16.6% broke semantic versioning, yet only 7.9% of client projects were affected, because most of those changes touched code no client called. Breakage is measured at the caller.

How do you ship a breaking change?

You ship it in the open, on a date, with a path. The steps below are in order, and the last one is the one most teams skip: telling the people who were affected that the thing they were waiting on has now happened.

  1. Decide whether it is one. Use the test above, not the diff. If two engineers disagree, it is breaking; the disagreement is evidence that a caller could reasonably have relied on the old behaviour.
  2. Version it. Under semantic versioning a breaking change is a major version. If you run a dated or versioned API, it goes in a new version and the old one keeps working until a stated date. If you cannot version, you are not shipping a breaking change, you are shipping an outage with a changelog entry. Which scheme carries the version is the subject of API versioning best practices.
  3. Write the entry before the code merges. The entry has a fixed shape: what changes, who it affects, what they must do, and by when. If you cannot fill in all four, the change is not ready. The release notes template puts these entries first, with a date rather than a version number, for exactly this reason.
  4. Give a deadline, not a release number. “Removed in v5” means nothing to someone who does not track your releases. “Stops working on 1 November 2026” means one thing to everyone.
  5. Provide the migration. The old call next to the new one. For a rename, say both names in the same sentence; for a removed field, say where the data went.
  6. Announce it everywhere the old behaviour was documented. The changelog, the endpoint’s docs page, the SDK release notes and the deprecation header on the response, if you have one.
  7. Close the loop. If a customer asked for the change, or reported the bug that led to it, tell them when it ships.

What does a good breaking-change entry look like?

A good entry names the affected caller in the first line, states the date, and includes the fix. Here is one for the tightened-validation case, in the shape we use:

Email addresses without a domain are rejected from 1 November 2026. POST /users and PATCH /users/:id currently accept email values such as alice@localhost. From 1 November these return 400 invalid_email. Affects any integration that creates users from internal directories. Migration: send a fully qualified address, or omit the field and set it later. No change is needed if your addresses already have a domain, which is true for 99.4% of accounts created this year.

Where that notice goes, and what else belongs beside it, is the subject of API changelog.

The percentage at the end is not decoration. It tells the reader whether to worry, which is the question they opened the entry with.

Why not just avoid them?

Because the alternative is worse. An API that never breaks anything accumulates every mistake it ever made: the misnamed field, the wrong default, the timestamp in local time. Each one is a tax on every new caller forever, to protect callers who could have migrated in an afternoon. The teams with the best reputations for stability break things rarely, on a schedule, with a migration path and a warning that reached the people it was for.

The mechanics of that warning are covered in deprecating an API. The entry itself is drafted like any other in the changelog feed: from the merged pull request, held for a human, then published where the affected callers already read.

FAQ

What is the difference between a breaking and a non-breaking change? A breaking change forces a correct caller to change their code, configuration or data to keep working. A non-breaking change leaves every existing call working with the same meaning, which is why additions are usually safe and removals, renames and tightened rules usually are not.

Does adding a required field count? Yes. Every existing call omits it, so every existing call now fails. Add it as optional with a sensible default, or version the endpoint.

Does a bug fix count? It can be. If callers depended on the buggy behaviour, fixing it breaks them, whatever the documentation said. Treat any fix that changes observable output as breaking unless you can show nobody relied on it.

Does semantic versioning apply to a web API? The rule does: breaking changes get a new major version and the old one keeps working for a stated period. The number often lives in the URL or a date header rather than a package version.

How much notice is enough? Enough for a caller to find the notice and do the work. Ninety days is a common floor for public APIs; longer for anything used in code that ships to end users and cannot be updated remotely.


The technical claims in this article have not been independently reviewed. If something here is wrong, tell us and we will correct it.

Related on changeloop: Release notes template, Developer docs

changeloop
The team building a closed-loop changelog. Your users ask, your team ships, the person who asked gets told.