Breaking API changes, explained
Last updated:
A breaking API change is any change to an API’s published contract that can make a client which was written correctly against the old contract fail against the new one — without the client changing anything. That single sentence is the whole theory; everything else in this article is its consequences: which changes qualify, which famous traps look harmless and are not, how to review a specification bump before it deploys, and how versioning strategies buy clients time. To apply it to two real documents, run the OpenAPI Diff on the old and new specs and read the verdicts alongside this page.
A contract is a promise about the future
An API specification is not documentation in the casual sense; it is a promise. When a spec declares that GET /pets accepts an optional limit parameter and answers 200 with an object containing a string name, every client author builds on exactly that: the parameter is omitted when unneeded, the status is branched on, the field is read as a string. The client’s correctness is judged against the promise, which is why the definition of breaking is asymmetric — a change is breaking if a correct old client can fail, even if most clients happen to survive it. One strict SDK generated from the old spec, failing on a changed type, is enough: the promise was broken, not the client.
This framing resolves most arguments about whether a change “counts”. Removing an endpoint is breaking because the promise included its existence. Adding an endpoint is not, because no old client promised itself anything about paths it never calls. A new optional response field is safe because old parsers ignore unknown fields — in any sane format, additive output cannot invalidate a reader. A new required input, by contrast, reaches into every existing request and invalidates the ones that omit it. The test is always the same: replay yesterday’s valid traffic against tomorrow’s rules and ask what, if anything, now fails.
Two corollaries matter in practice. First, the contract is what is published, not what the server happens to do today: an undocumented field clients discovered by sniffing traffic is not promised, and removing it is rude but not, strictly, breaking — which is an argument for documenting what you intend to keep and no more. Second, the contract includes the whole surface of the standards it stands on: HTTP method and status semantics as fixed by RFC 9110 are part of the promise, so redefining what a 200 or a 404 means is a contract change even if the spec file never mentions status meanings at all.
The classic traps
The most common production break is the required-field addition. A team adds a field to a request body, marks it required because the new business rule needs it, and ships — whereupon every existing client, none of which sends the field, starts receiving 400s. The safe versions are boring: add the field as optional with a server-side default, or require it only on a new endpoint. The second trap is the enum removal: a status value, a category, a sort order is retired because the new UI no longer offers it, while stored client data, mobile apps on old builds and partner integrations keep sending it for years. Enum values are nearly free to keep accepting; withdrawing one should be treated like deleting an endpoint.
Type changes are the third trap, and they come in widening and narrowing disguises. Narrowing — string to enum, integer to a smaller range, a free-form object to a fixed shape — rejects inputs that used to be valid, and is breaking on its face. Widening is subtler: changing a response field from string to string-or-null, or an integer id to a string id, looks generous and breaks every strict parser and generated SDK that was built for the old type. On the response side there is a mirror trap: a field that was always present becoming optional. Clients read it unconditionally; the first response that omits it is a null-pointer in someone else’s codebase.
The fourth trap is semantic, and no differ can catch it: the field keeps its name and type and changes its meaning. A price that silently switches from cents to dollars, a timestamp that moves from UTC to local time, a list that was sorted and no longer is — the spec diff is empty and every client is wrong. Semantic breaks are why the mechanical diff is a floor, not a ceiling, for review: it clears the changes that cannot break anyone, so human attention can concentrate on the changes that merely might.
Reviewing a vendor spec bump before you deploy
Vendors publish new spec versions constantly, and “bump the SDK and redeploy” is how quiet breaks reach production. A disciplined review takes fifteen minutes. First, obtain both documents — the spec your deployment actually runs against today, and the candidate — and diff them mechanically rather than reading a changelog, because changelogs are marketing documents: they list features, not removals. The OpenAPI Diff produces that list locally, which matters when the API is internal and its spec should not be pasted into a stranger’s hosted tool.
Second, work the breaking list as a checklist against your own usage. Most vendor breaks are theoretical for you: a removed endpoint you never call, a newly required field on a resource you never create. The dangerous entries are the ones that intersect your traffic — which is why the review needs your call inventory, not just the diff. Third, check the un-documented surface: statuses you branch on, error shapes you parse, headers you read. The same habit that protects webhook consumers — pinning and testing the payload contract, as in webhook signatures, explained — applies to any contract someone else controls.
Finally, stage it. Point a staging client at the new version, replay recorded production requests (a sanitised capture, handled the way the HAR guide prescribes), and compare responses before any user does. If the vendor offers a deprecation window or a version pin, take it: the cheapest breaking change is the one you adopt on your own schedule, after your clients are ready, rather than on the vendor’s release day.
Versioning strategies: URL, header, or never break at all
The classic answer to breaking change is a version identifier, and the classic argument is where to put it. URL versioning (/v1/, /v2/) is explicit, cache-friendly, trivially routable and visible in every log line; its cost is that it versions the whole API at once, so one breaking endpoint drags every client through a migration. Header versioning — a version header or a dated content-negotiation scheme — keeps URLs clean and can version individual resources, at the price of invisibility: the version disappears from logs, links and browser bars, and debugging becomes archaeology. Neither is wrong; the industry simply pays the URL cost more willingly because operability beats elegance at 3 a.m.
The deeper strategy is to need versions rarely. Additive-only evolution — never remove, never require, never retype; only add optional things — keeps a single version alive indefinitely, which is how the longest-lived public APIs in fact operate. It demands discipline up front: generous input tolerance, ids typed as strings from day one (so the range can grow), enums designed to be extended, and a deprecation process that warns for a long time before anything is withdrawn. Semantic versioning, as codified at semver.org, supplies the signalling layer the OpenAPI Initiative’s own specification deliberately leaves to publishers: a major bump announces a break, and clients can pin against it.
Whichever strategy is chosen, it only works as a social contract backed by mechanics: versions must actually run side by side for a stated window, deprecations must be announced where client authors will see them (changelog, response headers, direct notice for the big consumers), and usage of the old version must be measured so “nobody uses it any more” is a fact, not a hope. A version that is published but not operable in parallel is not a strategy; it is a rename of the break.
Where the differ fits
A mechanical differ earns its place at two moments. In review, it converts “did anything break?” from an afternoon of side-by-side reading into a list that can be checked off — and, just as usefully, it proves a negative: when a release claims to be additive-only, an empty breaking list is evidence, not vibes. In continuous integration, the same comparison can run on every pull request that touches a spec, failing the build when a breaking change arrives without the version bump or approval that the project’s policy demands. The classification table is the policy; the tool merely refuses to let it be forgotten at deadline time.
Its limits define the rest of the workflow. The differ reads the declared contract only: it cannot see behaviour, performance, semantics or the undocumented quirks clients depend on, and it summarises pathologically deep schemas rather than expanding them forever. So the complete gate is a pipeline, not a page: mechanical diff for the contract, contract tests replaying real client calls for behaviour, staged rollout for the semantics, and monitoring for the day all three miss something. Used that way, the diff is the cheapest layer of the four — fifteen seconds that delete the most embarrassing class of outage, the one where the break was written down in the spec all along and nobody compared the documents.
That is the habit this article is really arguing for. Specs are cheap to keep and cheaper to compare; incidents are not. Keep every published version of every contract you own or consume, diff each candidate against the version in production before it ships, and treat the breaking list as the release’s real agenda. The teams that do this stop having the argument about whether a change “should have” broken anyone — they have the list, dated and archived, from before it shipped.