06 · Backward Compatibility & Deprecation¶
Once an API has real clients, every change is a promise you might break. This module is about changing an API safely: what counts as a breaking change, how to version, and how to retire something without breaking everyone who depends on it.
Breaking vs. non-breaking changes¶
Safe (non-breaking), because well-behaved clients ignore what they don't recognize:
- Adding a new optional field to a response.
- Adding a new endpoint.
- Adding a new optional request parameter with a sensible default.
- Adding a new enum value, if clients are documented to handle unknown values gracefully.
Breaking, because existing clients will misbehave or crash:
- Removing or renaming a field or endpoint.
- Changing a field's type (
"id": 42→"id": "42"). - Making a previously optional request field required.
- Changing the meaning of an existing field or status code.
- Tightening validation that previously-valid requests now fail.
Versioning strategies¶
URL path: /v1/orders /v2/orders
Header: Accept: application/vnd.example.v2+json
Query param: /orders?version=2
URL-path versioning is the most common because it's visible, cacheable per-version, and trivial to route at the gateway (module 3). Header versioning keeps URLs stable but is harder to test with a browser and easy for clients to forget.
Both versions run simultaneously against the same underlying data —
v1 and v2 are just different serializations of the same resource.
Deprecation, done properly¶
Never remove a field or version overnight. Announce, signal, and give a runway:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Link: <https://docs.example.com/migration/v1-to-v2>; rel="deprecation"
The Deprecation and Sunset headers (both real, standardized HTTP
headers) let automated tooling and dashboards detect deprecated usage
without a human reading changelogs.
Worked example: retiring v1/orders¶
- Ship
v2alongsidev1, with the field rename and any other accumulated breaking changes bundled into one release, not drip-fed. - Announce the deprecation of
v1with a concrete sunset date, in the changelog, docs, and via theDeprecation/Sunsetheaders on everyv1response. - Instrument
v1usage — log which API keys are still calling it — so you know who to actually contact before the cutoff, rather than guessing. - Reach out directly to the remaining heavy
v1callers as the sunset date approaches; a header alone is easy to miss. - Sunset: after the date,
v1returns410 Gonewith a body pointing at the migration guide, rather than disappearing silently or (worse) returning wrong/broken data.
{
"error": {
"code": "version_sunset",
"message": "API v1 was retired on 2026-11-01. Migrate to v2.",
"docs": "https://docs.example.com/migration/v1-to-v2"
}
}
Additive-only evolution within a version¶
Many teams avoid ever bumping the major version by committing to
additive-only changes within v1 forever: new optional fields, new
endpoints, but never removing or repurposing anything. This works well
for years but eventually accumulates cruft (unused legacy fields kept
around solely for backward compatibility) — the trade-off is fewer
version migrations for clients, at the cost of a messier schema over
time.
How It Actually Works¶
Whether a change is "breaking" is a question about what real client code does when it parses your new response — not a subjective judgment call. Mechanically:
Adding a new field to a JSON response is non-breaking because most
JSON parsers and typed deserializers ignore unrecognized keys by default
(JSON.parse in JS, json.loads in Python, and most typed struct
deserializers with permissive mode) — old client code simply never reads
the new key.
Removing or renaming a field is breaking because any client code that
does response.data.price throws undefined/KeyError/a null-pointer
the instant that key stops existing — the parser doesn't fail, but the
client's own logic downstream does.
Changing a field's type (e.g. id: 42 becoming id: "42") is breaking
in typed clients (a generated SDK expecting int gets a runtime
deserialization exception) even though a loosely-typed JS client might
tolerate it silently — which is why "no breaking changes" has to be
defined against your strictest real consumer, not your most forgiving one.
Sunset headers (Sunset: Sat, 31 Dec 2026 23:59:59 GMT and
Deprecation: true) are read by well-behaved API client libraries and
surfaced as warnings — but this only works if the client's HTTP layer
actually inspects response headers, which many simple fetch/curl-based
integrations never do; this is why deprecation announcements also need an
out-of-band channel (email, changelog) rather than relying on headers
alone to be noticed.
Exercise¶
- Classify each as breaking or non-breaking: (a) adding a
currencyfield to an order response, (b) changingtotalfrom a number to a string, (c) making a previously-optionalemailfield required on signup, (d) adding a newDELETE /v1/orders/{id}endpoint. - Why is returning
410 Gonewith a migration link better than simply turning off a sunset endpoint with no response at all? - Compare URL-path versioning and header versioning: what's one advantage each has that the other lacks?
- A client depends on an undocumented quirk of your API (a bug that happens to be convenient for them). Is fixing that bug a breaking change? How would you handle it?