Backward Compatibility: The Real Rules
The safe list and the breaking list are shorter and stranger than intuition says. Adding an optional field is safe; making an optional field required is not; tightening validation, changing a default, or changing what a value means breaks clients without touching a single field name.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The safe list and the breaking list
Compatibility has a direction. Backward compatible means new server, old clients: every request an old client sends is still accepted, and every response it receives is still one it can process. The lists below follow from that definition mechanically — a change is safe exactly when no old client's requests become invalid and no old client's response handling becomes wrong.
The asymmetry between requests and responses is the part intuition misses. On the *request* side you may loosen (accept new optional inputs, widen what validates) but never tighten — an old client cannot start sending a field it has never heard of. On the *response* side you may add but never remove or repurpose — an old client can ignore a new field (if the contract told it to), but code reading a removed field breaks, and code reading a repurposed field breaks *silently*, which is worse.
| Change | Verdict | Why |
|---|---|---|
| Add optional request field / parameter | Safe | Old clients simply never send it; behavior without it must stay identical |
| Add response field | Safe* | *Only if the contract requires tolerant readers — otherwise strict deserializers crash (see SDK Design: The Contract's User Interface) |
| Add new endpoint / operation | Safe | Old clients never call it |
| Add enum value the server returns | Breaking in practice | Old clients switch on the closed set and hit the default arm (see Enum Evolution: The New Value That Broke Old Clients) |
| Make optional field required | Breaking | Every old request lacking it is now rejected |
| Remove or rename any field | Breaking | Rename = remove + add; old readers and writers both break (see Removing Fields Without Removing Consumers) |
Change a type (int → string, nullable → non-null response) | Breaking | Deserializers and null-handling written against the old type fail |
| Tighten validation (max length 500 → 100) | Breaking | Requests that succeeded yesterday fail today — a break with zero schema diff |
| Change a default or a field's meaning | Breaking, silently | No client errors; clients compute wrong results. The most expensive row in this table |
The breaks that never touch the schema
A schema diff catches perhaps half of real breaking changes. The other half are *semantic*: the shape is identical and the meaning moved. amount switches from cents to a decimal string of dollars. status: "completed" starts including refunded orders. The default page size drops from 100 to 25, and every unpaginated-by-laziness consumer silently loses data. Timestamps switch from server-local to UTC. An endpoint that returned items newest-first starts returning them oldest-first because an index changed — ordering nobody promised but everybody used, which is Hyrum's Law collecting its debt (see What an API Contract Actually Is).
Semantic breaks are the worst class because they fail open: no exception, no 4xx, no alert — just wrong numbers flowing into consumer systems. A duplicate-charge incident is loud and gets fixed in hours; a currency-unit change can corrupt a partner's books for a quarter before anyone reconciles. When you review a change for compatibility, the question is not "did the schema change?" but "could a client written against yesterday's behavior compute a different result tomorrow?"
Behavioral tightening deserves special paranoia. Rate limits lowered, timeouts shortened, previously-accepted garbage now rejected, authorization enforced where it accidentally was not — each is a *correctness improvement* for the provider and a breaking change for whoever depended on the slack. Sometimes you tighten anyway (the authorization case is not optional — see Authorization Design in the Contract); the discipline is knowing you are breaking someone and choosing it deliberately, with telemetry on who gets hit (see Consumer-Driven Evolution: Telemetry Before Breakage).
1# Monday2GET /orders/9 → { "amount": 1999 } # cents3 4# Tuesday, "cleanup" deploy5GET /orders/9 → { "amount": 19.99 } # dollars6 7# No client throws. Every client that8# compared, summed or invoiced amounts9# is now wrong by 100×, silently.1# amount keeps its old meaning forever2GET /orders/93→ {4 "amount": 1999, # cents, unchanged5 "amount_decimal": "19.99", # new, documented6 "currency": "EUR"7}8# old field deprecated on its own timeline9# (see [[removing-fields]])A field's meaning is frozen the moment the first consumer reads it. New meaning gets a new name; the old name gets a deprecation process. Reusing the name saves one field and costs a silent, unbounded reconciliation incident.
Compatibility is a two-sided contract: the tolerant reader
The safe list only works if consumers hold up their half. "Adding a response field is safe" is true exactly when clients are required to ignore unknown fields — the tolerant reader rule. Write it into the contract on day one: *clients must ignore unknown response fields and must handle unknown values in extensible enums*. Without that clause, some consumer will deserialize with strict mode, and your first additive change becomes their outage — and contractually, it will be your fault or ambiguous, which is worse.
The same clause-writing applies to everything the safe list assumes: field order is not promised, error message text is not promised (branch on codes — see An Error Taxonomy Clients Can Branch On), response ordering is only promised where documented (see Sorting: Determinism or Drift). Every explicit non-guarantee is a change you can make later without a meeting. This is the cheapest evolution investment an API can make, and it only works if made before consumers integrate.
Verify mechanically, not by vigilance. Contract diffing in CI (an OpenAPI diff that fails the build on breaking changes — see OpenAPI: Describing the Contract, Not Designing It) catches the structural half; contract tests where consumers pin their expectations catch part of the behavioral half (see Testing the Contract, Not Just the Code). The semantic half — meaning changes — has no tool. It is caught by review culture that asks the Tuesday question: *what does a client written on Monday do with this response?*
GET /v1/orders/ord_812 HTTP/1.1 Authorization: Bearer <token> Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "ord_812",
"status": "processing",
"amount": 1999,
"fulfillment_eta": "2026-09-02", ← new this release
"carrier": null ← new this release
}
· 2023 mobile build: ignores both fields — contract said it must
· 2026 web client: renders the ETA
· zero coordinated deploysKey points
- Backward compatible = new server, old clients: old requests still accepted, old response handling still correct. Every rule derives from that.
- Requests may loosen but never tighten; responses may add but never remove or repurpose.
- Optional→required, type changes, tightened validation and lowered limits are breaking changes with no field renamed.
- Semantic breaks — meaning, units, defaults, implicit ordering — produce no errors, only wrong results; they are the most expensive class.
- The safe list requires a consumer-side clause written on day one: tolerant readers, unknown-enum handling, explicit non-guarantees.
- Enforce structurally in CI with contract diffing; the semantic half is caught only by asking what Monday's client does with Tuesday's response.
Compatibility Analyzer
Change the contract and observe which guarantee moves.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: ships v1 with no compatibility rules and no tolerant-reader clause; "we will be careful" is the policy.
- 2Team → deploy: adds a response field — the safest possible change; a partner's strict deserializer rejects the unknown key and their integration goes down.
- 3Team → overcorrection: freezes the response shape entirely; needed data gets bolted on through a second endpoint.
- 4Team → deploy: "fixes"
amountfrom cents to dollars in place, since schema-wise nothing changed; no test fails. - 5Partner → reconciliation: discovers a quarter of invoices off by 100× — the silent break outlived every loud one.
- Old clients fail on requests and responses that worked yesterday — mobile builds in the field cannot be hotfixed, so the breakage window is months, not minutes.
- Silent semantic breaks corrupt downstream consumer data: books, dashboards and decisions built on wrongly-interpreted values.
- The provider loses release velocity: after one bad break, every deploy needs a compatibility séance because no written rules exist to consult.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Publish the safe list and the breaking list as part of the contract, plus the consumer obligations (tolerant reader, unknown-enum handling) that make the safe list true.
- • Never repurpose: new meaning gets a new field name, new behavior gets a new parameter or endpoint; the old one enters deprecation instead of mutating.
- • Gate deploys on contract diffing in CI — a spec diff that classifies changes as safe/breaking and fails the build on unapproved breaks (see [[openapi]] and [[api-testing]]).
- • State non-guarantees explicitly (ordering, message text, field order, timing) so behavior consumers should not depend on is contractually changeable.
- • Segment error rates by client version / SDK version / API key after every deploy: a spike isolated to old clients is a compatibility break announcing itself (see [[api-metrics]]).
- • Watch 400-response rates per endpoint after "validation improvements" — tightened validation breaks show up as rejected requests from unchanged clients.
- • Semantic breaks surface in support tickets and reconciliation disputes, weeks late; treat any "the numbers changed" report as a possible in-place meaning change.
- • An API with tolerant readers and a written safe list evolves continuously: most quarters of product work ship as additive changes with zero consumer coordination.
- • Changes outside the safe list are not forbidden — they are routed: through a new field ([[removing-fields]]), a new endpoint ([[api-migration]]), or a version ([[versioning]]), each with a consumer-movement plan.
- • The rule set itself can strengthen additively (promising more) but weakening a promise consumers hold is itself a breaking change.
- • Never-repurpose accretes surface: `amount` and `amount_decimal` coexist for years, and every reader of the API sees the scar tissue.
- • Strict CI contract-gating occasionally blocks changes that are technically breaking and practically harmless — the override path must exist, and every override is a small bet.
- • Tolerant-reader clauses push work onto consumers (lenient parsing, forward-compatible enum handling) that strict schemas would have caught for them at compile time.