Backward Compatibility: Why Your API Cannot Change
Someone renamed a JSON field from user_name to username because it was inconsistent with the rest of the API. Tested it. Deployed it. Three hours later, six integrations that nobody knew existed started failing silently - they were sending user_name that the API now ignored. One of them was a payment processor webhook listener that stopped recording transactions. Nobody noticed for two days.
APIs are promises. Once a client depends on a field name, a status code, or a response shape, that shape is committed. Not by contract, but by dependency. The client code was written against your API, and it hasn’t been updated - because the client can’t be updated if you don’t control it, and often you don’t.
What Counts as Breaking
A breaking change is any change that causes a client that worked before to not work after, without the client changing.
Breaking:
- Renaming a field in a request or response
- Removing a field from a response
- Changing a field’s type (string to integer, integer to array)
- Changing a status code meaning (200 that was success now means “pending”)
- Making an optional request field required
- Changing authentication requirements
- Changing error response structure
- Changing a URL path or query parameter name
Not breaking (usually):
- Adding a new optional field to a response
- Adding a new optional request parameter
- Adding a new endpoint
- Adding a new enum value (be careful - clients that switch on enum values break)
- Making a required request field optional
The asymmetry: you can almost always add, but you cannot remove or rename.
Who Breaks When You Break
The clients you know about: other services your team maintains, a web frontend you can update simultaneously.
The clients you don’t know about: mobile apps at old versions, external partners who integrated months ago, internal tools built by other teams, scripts someone wrote and never mentioned, monitoring and analytics integrations, webhook receivers.
The mobile app problem deserves its own callout. A web frontend can be updated with a deployment. Mobile apps can’t - users decide when to update. A year after launch, 20% of users might still be on a version that calls your old API. If you break that version’s API calls, you break the app for those users. Mobile teams have to maintain backward compatibility longer than any other client type.
The Versioning Options
URL versioning (/v1/users, /v2/users):
- Clear and explicit - clients can’t miss which version they’re on
- Easy to route differently at the load balancer
- You maintain multiple versions simultaneously until old versions are deprecated
- Requires a deprecation and sunset policy with communication to clients
Header versioning (Accept: application/vnd.api.v2+json):
- Cleaner URLs
- Harder for clients to discover and debug
- Some HTTP clients don’t easily set custom headers
No versioning (additive-only evolution):
- Requires extreme discipline - every change must be additive
- Works until you need to make a genuinely breaking change
- Simplest operationally until it breaks down
The right choice depends on who your clients are. Internal APIs between services you control can often use additive-only evolution with coordinated deploys. Public APIs, partner integrations, or mobile APIs need explicit versioning.
Evolving Without Breaking
The strategy is always: add, then deprecate, then remove - never change in place.
Renaming a field:
# Phase 1: add the new name, keep the old
return {
"username": user.username, # new name
"user_name": user.username, # old name - still included, same value
}
# Phase 2: log usage of old field to find remaining clients
if 'user_name' in request.json:
logger.warning('deprecated field user_name used', extra={
'client_id': request.headers.get('X-Client-Id'),
'endpoint': request.path,
})
# Phase 3: after confirmed zero usage, remove old field
The deprecation logging is the mechanism by which you know when it’s safe to remove. “We sent emails to known integrators” is not enough. Logs showing zero usage for 30 days is.
Adding a required field to a request: Never add a required field to an existing endpoint. Add it as optional first, give clients time to start sending it, then make it required in a new version.
Changing behavior for an existing status code: Don’t. Add a new endpoint or a new response field. If you must, version the endpoint.
Deprecation as a Process
Deprecation means: this still works, but we’re planning to remove it. The removal date should be communicated, not guessed.
A deprecation policy needs:
- A concrete removal date
- A way for clients to discover the deprecation (deprecation header in responses, documentation, direct communication to registered API consumers)
- A migration path (what should clients do instead)
The Deprecation and Sunset HTTP headers are standardized ways to communicate this:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
A client reading these headers knows the endpoint is deprecated, when it will stop working, and where to go instead.
The Internal API Trap
It’s tempting to think backward compatibility only matters for public APIs. Internal APIs break in the same ways, just with a smaller blast radius.
If service A calls service B’s API, and service B “just renames a field”, service A breaks the moment service B deploys. In a microservices environment with many teams, this is a coordination problem that happens constantly. The solution is the same: additive changes, versioning for breaking changes, consumer-driven contract testing to catch breaks before deployment.
The rule: any API that has more than one caller, across more than one deployment unit, is a public API from a backward compatibility perspective. Treat it accordingly.