Breaking Change
A breaking change is one that stops an existing consumer from working — or makes it wrong. The definition is behavioural on purpose, because the structural version (we removed a field) both misses cases and produces false alarms, and because it puts the judgment where the evidence is: in the consumers’ code rather than in the provider’s intent.
The practical consequence is uncomfortable and worth accepting early. The provider does not get to decide what counts as compatible. A change is compatible if the consumers survive it, which is a fact about them.
The usual cases, and who they break
| Change | Who it breaks |
|---|---|
| Adding an optional field | Usually nobody — but only because consumers ignore what they do not recognize. A strict validator, or code that persists whole payloads into a fixed schema, breaks anyway |
| Adding a required input | Every existing caller, immediately and visibly |
| Removing a field, or leaving it always empty | Anyone reading it. Emptying a field is removal with extra steps |
| Adding a new enum value | Consumers that handle the old set exhaustively — a common pattern, and one nobody remembers is there |
| Tightening validation | Callers whose input was previously accepted, including those relying on a bug you just fixed |
| Changing what a field means | Everyone, silently |
The first row is worth reading carefully, because it explains why additive changes are safe is a convention rather than a law. It holds when consumers ignore unknown content, and standards say so explicitly where they can: RFC 9457 requires that clients “MUST ignore any such extensions that they don’t recognize,” precisely so that the specification allows problem types “to evolve and include additional information in the future.” Where no such rule was stated and enforced, an addition can break a consumer that validates strictly.
The quiet ones
Several of the changes in that table announce themselves: a failed parse, a rejected request, an unhandled case. Someone sees those, usually within hours.
The trap is assuming that only the last row is quiet. Whether a break is visible depends on what the consumer does with the thing you changed, not on whether the change was structural. Remove an optional field and a consumer that substitutes a default keeps running and computes something different. Add an enum value and a consumer whose branch has a catch-all arm files it under the wrong category. Tighten a nullable field to non-null and a consumer that treated null as not applicable now treats the substituted value as real. In each case parsing succeeded, nothing was logged, and the output changed.
Redefining a field’s meaning while keeping its type produces nothing. Change active customers to exclude trial accounts and every consumer keeps working, every dashboard keeps rendering, and every number is now smaller than last month’s for a reason nobody can locate. The discovery happens in a meeting, weeks later, and the investigation costs more than the change saved.
Two things follow. A meaning change deserves the same handling as a removal — announced, versioned if consumers cannot all move, and dated so that a reader comparing periods can tell where the definition changed. And it argues for writing meaning down in the first place: a field whose interpretation was never stated will be interpreted, and the interpretation becomes an obligation the provider cannot see.
Four things providers assume wrongly
- It is a bug fix, so it cannot be breaking. Consumers adapt to behaviour, not to specifications. Code that worked around the bug breaks when the bug does not.
- It is deprecated, so nobody uses it. Deprecation is an announcement. Usage is a measurement, and only the second one settles the question.
- Nobody has complained about it, so nobody depends on it. Silence is what a working dependency sounds like.
- The client will handle it. There is rarely one client. There are several, of different ages, and at least one belongs to someone you cannot phone.
The compatibility direction is also a choice rather than a given, and the two directions protect different people: new readers handling old data is not the same guarantee as old readers handling new data, and which one you need depends on who upgrades first. That is the same reasoning as schema compatibility, and the registry rules there are worth reading for the direction vocabulary — including that “Avro, Protobuf, and JSON Schema have different compatibility rules,” so a change that passes in one format may not in another.
Deciding, and avoiding
A four-step judgment covers most proposed changes, and the first step is the one usually skipped.
- Find out who calls it, per consumer, from usage data rather than from memory.
- Ask what their code does with the thing you are changing — reads it, branches on it, stores it, validates it. Each implies a different exposure.
- Check whether the failure would be visible. An invisible break is worse than a loud one and deserves more caution, not less.
- Then choose: ship it, version it, or reshape it into something compatible.
Three reshapings avoid most versions. Add rather than change, keeping both forms populated during a transition. Make the new behaviour opt-in through a parameter or header, so existing callers are untouched. Or expand, migrate, contract — accept both shapes, move consumers, then remove the old one. Where a version is genuinely needed, semantic versioning gives the signal its meaning, and shipping a breaking change without the corresponding bump is what empties that signal of value. The retirement half — announcing, measuring, and removing — is covered under deprecation.
How this fits with the rest of an interface’s obligations — errors, retry safety, ordering, and limits — is worked through in You Cannot Un-publish an API.
References: RFC 9457, Problem Details for HTTP APIs; Confluent, Schema Evolution and Compatibility.
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
