Backward Compatibility
Backward compatibility is the promise that existing consumers will not break when you change your API. It is the foundation of trust between an API provider and its consumers. Break backward compatibility carelessly, and developers will stop building on your platform.
The difficulty is that not all changes are created equal. Some changes are safe by definition. Others are breaking by definition. And some fall into a gray area where the answer depends on how consumers actually use your API.
Safe Changes
These changes will not break existing consumers:
Adding New Fields to Responses
Adding a field to a response body is safe. Existing consumers ignore fields they do not recognize.
{
"id": "user_123",
"name": "Jane Doe",
"email": "jane@example.com"
}
Adding a phone field:
{
"id": "user_123",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+1-555-0100"
}
Well-written clients deserialize only the fields they expect and discard the rest. This is the most common type of API evolution.
Adding New Optional Request Parameters
Adding an optional query parameter or request body field is safe. Existing requests that omit the new parameter continue to work.
POST /v1/orders
{
"product_id": "prod_abc",
"quantity": 2
}
Adding an optional gift_message field does not break existing callers:
POST /v1/orders
{
"product_id": "prod_abc",
"quantity": 2,
"gift_message": "Happy birthday!"
}
Adding New Endpoints
Adding entirely new endpoints never breaks existing consumers. They simply do not call the new endpoints.
Adding New Enum Values
This is a gray area. Adding a new value to an enum (like adding "processing" to a status field that previously had "pending" and "completed") is technically additive. But consumers with exhaustive switch statements will break.
Stripe handles this by documenting that enum fields may gain new values and consumers should handle unknown values gracefully.
Breaking Changes
These changes will break existing consumers:
Removing Fields
Removing a field from a response is breaking. Any consumer that reads that field will fail.
{
"id": "user_123",
"name": "Jane Doe",
"email": "jane@example.com",
"username": "janedoe"
}
Removing username breaks every consumer that references response.username. This is the most common source of API breakage.
Renaming Fields
Renaming a field is equivalent to removing the old field and adding a new one. Both are breaking.
{
"email_address": "jane@example.com"
}
Renaming email_address to email breaks consumers that reference the old name, even though the data is identical.
Changing Field Types
Changing a field from a string to an integer, or from a scalar to an array, breaks type-safe consumers immediately.
{
"price": "19.99"
}
Changing to:
{
"price": 19.99
}
Any consumer that treats price as a string (for display formatting, for example) will fail when it receives a number.
Making Optional Fields Required
If a request field was optional and becomes required, existing requests that omit it will start failing with validation errors.
Changing Error Formats
Consumers parse error responses too. Changing your error format breaks error-handling code.
Postel's Law
Jon Postel's robustness principle: "Be liberal in what you accept, and conservative in what you send."
Applied to APIs:
- Liberal in acceptance — accept both
emailandemail_addressduring a migration. Accept both string and integer for fields where the type changed. Trim whitespace from strings. Ignore unexpected fields in request bodies. - Conservative in sending — always send responses in the documented format. Do not add experimental fields without versioning. Do not change field types or remove fields.
{
"email": "jane@example.com",
"email_address": "jane@example.com"
}
During a migration, you might send both the old field name and the new field name in responses, giving consumers time to migrate.
Stripe applies Postel's Law rigorously. Their API accepts slightly malformed input where the intent is clear, and their responses are meticulously consistent.
The Expand-Contract Pattern
The expand-contract pattern (also called parallel change) is the safest way to make breaking changes:
Step 1: Expand
Add the new field alongside the old field. Both are populated in responses. Both are accepted in requests.
{
"id": "user_123",
"name": "Jane Doe",
"email_address": "jane@example.com",
"email": "jane@example.com"
}
The old field email_address and the new field email coexist. Existing consumers continue using email_address. New consumers use email.
Step 2: Migrate Consumers
Notify consumers that email_address is deprecated. Provide migration guides. Monitor usage of the deprecated field. Give consumers a deadline — typically 6 to 12 months.
Deprecation: email_address is deprecated. Use email instead.
Sunset: 2025-06-01
Step 3: Contract
After the migration deadline, remove the old field. Only email remains.
{
"id": "user_123",
"name": "Jane Doe",
"email": "jane@example.com"
}
This pattern is slower than a direct breaking change, but it prevents production outages for your consumers.
Feature Flags for API Changes
Feature flags let you roll out API changes incrementally, testing with a subset of consumers before making the change universal.
POST /v1/users
X-Feature-Flags: new-response-format
The server checks the feature flag and returns either the old or new response format. This gives you a controlled rollout:
- Enable the new format for internal consumers
- Enable for beta partners who opted in
- Enable for a percentage of traffic
- Enable for everyone
- Remove the flag and the old code path
GitHub uses feature flags extensively for API previews:
Accept: application/vnd.github.mercy-preview+json
This header enables a preview feature (topics on repositories) that is not yet part of the stable API. Consumers opt in explicitly.
Compatibility Testing
Automated tests should verify backward compatibility on every deploy:
Contract Tests
Contract tests verify that your API still satisfies its documented contract. If a field exists in the contract, the test fails when it is missing from the response.
Test: GET /v1/users/123
Expected fields: id, name, email, created_at
Result: PASS - all fields present with correct types
Consumer-Driven Contract Tests
Tools like Pact let consumers define the contract they depend on. The provider runs these consumer-defined tests in their CI pipeline.
{
"consumer": "billing-service",
"provider": "user-service",
"interactions": [
{
"description": "get user by ID",
"request": {
"method": "GET",
"path": "/v1/users/123"
},
"response": {
"status": 200,
"body": {
"id": "user_123",
"email": "jane@example.com"
}
}
}
]
}
If the user service removes the email field, the billing service's contract test fails before the change reaches production.
Schema Validation
OpenAPI schema diffing tools (like oasdiff) can detect breaking changes automatically by comparing schema versions:
$ oasdiff breaking old-spec.yaml new-spec.yaml
1 breaking changes detected:
GET /v1/users/{id}
- response property 'username' removed
Semantic Versioning for APIs
Semantic versioning (major.minor.patch) maps to API changes:
- Patch (1.0.1) — bug fixes that do not change the contract
- Minor (1.1.0) — additive changes: new fields, new endpoints, new optional parameters
- Major (2.0.0) — breaking changes: removed fields, renamed fields, changed types
Not every API uses semver explicitly, but the mental model is valuable. Ask yourself: "Is this change additive or breaking?" If additive, no version bump needed. If breaking, you need a plan.
Common Pitfalls
- Assuming all consumers are well-behaved — some consumers use strict deserialization that fails on unknown fields. Adding a field is theoretically safe but can break poorly written clients. Document that responses may include additional fields.
- Changing defaults silently — if a query parameter defaults to
limit=20and you change it tolimit=50, that is a behavioral breaking change even though the contract looks the same. Document and version default changes. - Ignoring enum expansion — adding new values to enums breaks consumers with exhaustive matching. Document that enums may gain new values.
- Skipping the expand phase — going directly from old format to new format without a coexistence period. The expand-contract pattern exists because consumers need migration time.
- No monitoring — deploying a change without monitoring error rates. A spike in 4xx or 5xx errors after a deploy indicates you broke something.
- Treating backward compatibility as optional — every breaking change costs your consumers engineering time. Respect that cost.
Key Takeaways
- Adding fields and endpoints is safe. Removing, renaming, or changing types is breaking. Know the difference before every API change.
- Postel's Law is the guiding principle: accept flexibly, respond strictly. This creates room for evolution.
- The expand-contract pattern is the safest migration path: add the new alongside the old, migrate consumers, then remove the old.
- Feature flags give you controlled rollout of API changes. Use them for significant changes to limit blast radius.
- Automated compatibility testing (contract tests, schema diffing) catches breaking changes before they reach production.
- Backward compatibility is not a technical convenience — it is a trust contract with your consumers. Breaking it costs real engineering time and real goodwill.