Backwards Compatibility Is a Promise. Stop Breaking It.
by Eric Hanson, Backend Developer at Clean Systems Consulting
What backwards compatibility actually means
It means that code written against your API today will continue to work correctly after you make changes — without any modifications on the client's part. Not mostly work. Not work except for this one edge case. Work.
This sounds strict because it is. The promise is binary. You either maintained it or you did not.
The counterargument is that strict backwards compatibility is too constraining — it prevents you from fixing mistakes, improving your schema, and evolving the API. That is true. The resolution is not to break compatibility silently. It is to use versioning to make breaking changes on a separate version while maintaining the old contract on the existing one.
Breaking backwards compatibility without a version bump is not evolution. It is unilateral breakage passed off as normal maintenance.
The forms it takes
The obvious violations are easy to identify: removing fields, renaming endpoints, changing authentication requirements. Most teams know better than to do these without a version bump.
The subtle violations are where the real damage happens.
Changing response field types silently: A field that returned integers starts returning floats because the underlying calculation changed. In statically typed client languages, this throws a deserialization exception. The client worked yesterday. Nothing in your changelog mentioned this.
Adding server-side validation to previously accepted values:
Your API accepted any string in the currency field. You add ISO 4217 validation. Now clients passing "USD " (with a trailing space) get 400 errors on previously valid requests.
Changing sort order without documentation:
A paginated endpoint returned results ordered by creation time, ascending. A performance optimization changed the default to id order, which is almost always the same but diverges for bulk-imported data. Clients doing cursor-based pagination now miss records.
Changing error codes:
A client handles 402 Payment Required for overdue accounts. You change it to 403 Forbidden because it is technically more correct. The client's error handling no longer catches subscription failures.
None of these feel like breaking changes when you are writing them. They all are.
Enforcement is the only thing that works
Policy documents do not prevent breaking changes. Individual discipline does not prevent breaking changes. The only reliable prevention is automated enforcement.
OpenAPI schema diffing in CI catches structural changes — field removals, type changes, required field additions. Tools like oasdiff can be configured to fail a PR that introduces breaking changes without an explicit version bump:
# .github/workflows/api-compat.yml
- name: Check API backwards compatibility
run: |
oasdiff breaking openapi-main.yaml openapi-pr.yaml \
--fail-on ERR \
--format text
This does not catch behavioral changes — sort order, validation rules, status code semantics. Those require contract tests.
Contract tests run the actual client expectations against the current API:
// A contract the client registered
describe('GET /v1/users/:id', () => {
it('returns user with string id field', async () => {
const res = await apiClient.get('/v1/users/42');
expect(typeof res.body.id).toBe('string');
expect(res.status).toBe(200);
});
it('returns 404 for unknown user, not 400', async () => {
const res = await apiClient.get('/v1/users/99999999');
expect(res.status).toBe(404);
});
});
These tests encode the implicit contract — not just the happy path but the edge case behaviors clients depend on. Run them in CI against every server-side change.
When you have to break something
There are cases where backwards compatibility genuinely cannot be maintained — a security vulnerability requires invalidating all tokens, a fundamental data model change has no backwards-compatible path.
In these cases:
- Make the break explicit. Bump the version. Do not slip it into a patch release and hope nobody notices.
- Communicate the change with a concrete migration timeline before you ship.
- Provide a compatibility shim if at all possible during a migration window.
- For security-driven breaks, explain the reason clearly. Developers accept necessary breakage they understand; they resent arbitrary breakage they do not.
The bar for "genuinely cannot be maintained" should be high. In most cases, there is a backwards-compatible path — it just takes more engineering effort than the non-compatible path. That effort is the cost of having made a contract with your users.
The organizational question
Breaking backwards compatibility is often not a technical decision — it is a prioritization decision. The engineering work to maintain compatibility gets weighed against feature velocity and the path of least resistance wins.
The solution is to make backwards compatibility breakage visible and costly in the process. Require explicit sign-off from an API owner before any breaking change ships. Count public breaking changes as incidents. Track them. When the cost of breaking compatibility is visible, it gets weighed correctly.