
API Versioning Without Breaking Clients
API versioning without breaking clients is mostly a compatibility policy problem, not a debate about whether the version belongs in the URL path, a header, or a media type.
The failure usually starts with a reasonable change. A team renames a response field, tightens validation, changes an enum value, or adjusts pagination defaults. The API still works for the newest frontend. The integration tests still pass. Then an older mobile app, partner integration, background worker, or generated SDK starts failing because it depended on the old contract.
This article is part of the API Correctness cluster. It sits beside API Contract Testing: Prevent Breaking Clients Before Release and How to Write API Integration Tests: contract tests catch accidental incompatibility, integration tests prove real endpoint behavior, and versioning gives planned breaking changes a migration path. The same planned-change path is collected in Testing And Software Delivery, alongside rollout controls and live-schema change guidance.
Versioning Is A Compatibility Contract
The useful question is not:
Should this API use
/v1,api-version, orAcceptheaders?
The useful question is:
Can an existing client keep working after this release?
Versioning exists for the moments when the answer is no.
If a change can be made backward-compatible, do that first. Add an optional field. Accept both old and new input names temporarily. Keep the old response field populated. Preserve defaults that clients may depend on. Publish the new behavior without forcing every client to migrate on the same day.
When the change cannot be made safely in the existing contract, create a new version and run it as a migration:
- keep the old version stable
- publish the new version
- document the mapping
- instrument usage by client and version
- migrate clients deliberately
- deprecate the old version
- sunset it only after the support window and usage threshold are satisfied
Google's API Improvement Proposals describe APIs as contracts with users and separate compatibility into source, wire, and semantic compatibility. That last category matters a lot in HTTP APIs: a change can preserve JSON syntax and still break reasonable client behavior.
Official references:
What Counts As A Breaking API Change
Teams often undercount breaking changes because they look only at field removal.
Field removal is breaking, but it is not the only breaking change.
| Change | Usually breaking? | Why clients break |
|---|---|---|
| Remove a response field | Yes | Deserializers, UI code, mappings, and database sync jobs fail |
Rename name to fullName | Yes | A rename is remove plus add from the client's point of view |
Change status from string to object | Yes | Typed clients and JSON parsers expect the old shape |
| Add a required request field | Yes | Old clients do not send it |
| Tighten validation for previously accepted input | Usually | Old clients may submit values that used to work |
| Change default sort order or pagination size | Often | Clients may assume stable ordering, counts, or "all rows" |
| Add a response field | Usually no | Robust clients should ignore unknown fields |
| Add a new optional request parameter with the same default | Usually no | Old clients get the same behavior |
| Add an enum value to a response | Risky | Some clients treat enums as closed lists |
| Change error status or error body shape | Often | Retry, validation, and support tooling often parse error details |
| Change meaning without changing schema | Often | Semantic compatibility breaks even when the wire shape survives |
The uncomfortable part is that client code often depends on undocumented behavior.
If GET /orders has always returned newest orders first, changing it to oldest first can break dashboards and polling jobs even if the OpenAPI schema is unchanged. If an error used to return 409 and now returns 422, an SDK may stop retrying or showing the right user message. If an enum grows from pending | paid | failed to include requires_review, a generated client may throw when it sees the new value.
This is why versioning policy must define both wire compatibility and behavior compatibility.
A Concrete v1 To v2 Example
Suppose v1 has this endpoint:
GET /v1/customers/cus_123
v1 response:
{
"id": "cus_123",
"name": "Ada Lovelace",
"status": "active",
"createdAt": "2026-02-01T12:30:00Z"
}
The product now needs:
- separate first and last name fields
- a richer lifecycle state
- a nested profile object
- a new error model for deleted customers
The tempting change is to edit v1 in place:
{
"id": "cus_123",
"profile": {
"firstName": "Ada",
"lastName": "Lovelace"
},
"lifecycleState": "enabled",
"createdAt": "2026-02-01T12:30:00Z"
}
That is a breaking change. It removes name, changes the status field's name and value vocabulary, and changes the response shape.
A safer migration can start inside v1:
{
"id": "cus_123",
"name": "Ada Lovelace",
"status": "active",
"firstName": "Ada",
"lastName": "Lovelace",
"createdAt": "2026-02-01T12:30:00Z"
}
That is additive. Old clients keep reading name and status. New clients can begin reading firstName and lastName. Telemetry can show which clients still request, deserialize, or display the old fields.
Only when the new semantics cannot fit safely in v1 should v2 appear:
GET /v2/customers/cus_123
v2 response:
{
"id": "cus_123",
"profile": {
"firstName": "Ada",
"lastName": "Lovelace"
},
"lifecycleState": "enabled",
"createdAt": "2026-02-01T12:30:00Z"
}
The version boundary now means something real: v1 keeps the old contract, and v2 exposes the new contract.
Do Not Version Every Additive Change
A new version should not be the default for every improvement.
If every new field, optional filter, or endpoint creates another public version, the API becomes expensive for everyone:
- clients cannot tell which changes require migration
- documentation fragments across versions
- SDKs multiply
- server code keeps compatibility branches forever
- tests must cover too many surfaces
- support cannot quickly identify what contract a client is using
Backward-compatible changes should usually be released in the current major version.
Good v1 additions:
- add an optional response field
- add an optional request parameter with a default that preserves old behavior
- add a new endpoint
- add a new link or metadata object while keeping old fields
- expand documentation for existing behavior
Risky additions that need extra care:
- new response enum values
- new pagination defaults
- new validation warnings
- new webhook event types
- new fields that some clients may reject because their parser is strict
The policy should say what happens when a client is not robust to unknown fields. For public APIs, the safest documentation is explicit: clients must ignore unknown response fields, but the API team should still use contract tests and telemetry before assuming they do.
Choose The Version Surface For Operations, Not Taste
The three common REST versioning surfaces are path, header, and media type.
| Strategy | Example | Strength | Cost |
|---|---|---|---|
| Path versioning | /v1/orders | Easy to see, route, cache, document, and debug | Can encourage duplicated controllers |
| Query parameter | /orders?api-version=2026-05-13 | Explicit and easy for manual calls | Can be awkward for cache keys, links, and route organization |
| Custom header | API-Version: 2 | Keeps URLs stable and supports per-client negotiation | Easier to miss in proxies, logs, SDKs, and manual debugging |
| Media type | Accept: application/vnd.example.v2+json | Ties version to representation negotiation | More complex for clients and harder for many teams to operate |
| Date-based version | API-Version: 2026-05-13 | Good for per-client compatibility snapshots | Requires strong infrastructure and support discipline |
For many small and medium teams, path versioning is the most practical default because humans and tools can see it immediately:
GET /v1/orders/ord_123
GET /v2/orders/ord_123
That does not make path versioning universally best. It simply makes the operational cost clear.
If you choose a header, use a meaningful non-X- header name. RFC 6648 deprecates the X- naming convention for newly defined parameters, including application protocol parameters, because those names create migration and interoperability problems later.
Official reference:
The version surface is less important than the policy behind it. A well-run /v1 and /v2 is safer than a sophisticated media-type strategy nobody enforces.
Separate Versioning From Contract Testing
Versioning and contract testing are related, but they solve different problems.
Contract testing asks:
Did this proposed release accidentally break a published contract?
Versioning asks:
This change is intentionally incompatible. How do old and new clients coexist while migration happens?
The workflow should connect them:
- A pull request changes an OpenAPI schema, response fixture, error body, or example.
- The contract gate detects an incompatible change.
- The author either makes the change backward-compatible or attaches a versioning plan.
- The versioning plan defines
v2, migration docs, telemetry, support window, and deprecation timeline.
That keeps the team from treating the compatibility gate as a nuisance.
For the contract side of this process, use API Contract Testing: Prevent Breaking Clients Before Release. This guide covers the planned migration path after the gate says: yes, this is a real breaking change.
Run Old And New Versions Side By Side
The dangerous migration is a flag day:
Monday 09:00 - deploy v2
Monday 09:01 - remove v1 behavior
Monday 09:02 - hope every client already changed
A safer migration runs versions side by side:
Phase 1: v1 stable, v2 hidden or internal
Phase 2: v2 documented for early clients
Phase 3: v1 and v2 both supported
Phase 4: v1 deprecated, v2 preferred
Phase 5: v1 sunset after usage threshold and support window
Google Cloud Endpoints documentation makes the same broad distinction: backward-compatible changes can increment a minor version for deployment history, while backward-incompatible changes should use a new major version and run versions concurrently so customers control when they migrate.
Official reference:
Implementation can still share most internal code. Do not copy an entire service just because the public contract changed.
A common shape is:
app.get('/v1/customers/:id', async (req, res) => {
const customer = await customers.get(req.params.id)
res.json(toCustomerV1(customer))
})
app.get('/v2/customers/:id', async (req, res) => {
const customer = await customers.get(req.params.id)
res.json(toCustomerV2(customer))
})
The domain model can evolve once. The public representation is mapped per version.
That mapping layer is not busywork. It is where compatibility becomes explicit:
function toCustomerV1(customer: Customer) {
return {
id: customer.id,
name: `${customer.firstName} ${customer.lastName}`,
status: customer.enabled ? 'active' : 'disabled',
createdAt: customer.createdAt.toISOString(),
}
}
function toCustomerV2(customer: Customer) {
return {
id: customer.id,
profile: {
firstName: customer.firstName,
lastName: customer.lastName,
},
lifecycleState: customer.enabled ? 'enabled' : 'disabled',
createdAt: customer.createdAt.toISOString(),
}
}
This also gives tests a clean target. Integration tests can assert v1 behavior stays stable while v2 evolves. For that request-path testing layer, see How to Write API Integration Tests.
Deprecation Is Not Removal
Deprecation means: this version still works, but clients should stop building new dependencies on it and should migrate.
Removal means: this version is no longer available.
Do not blur those states.
A practical lifecycle looks like this:
| State | Client meaning | Server behavior |
|---|---|---|
| Supported | Safe for current and new integrations | Works normally, receives compatible improvements |
| Deprecated | Existing clients may continue, new clients avoid it | Works normally, emits warnings and migration links |
| Sunset-ready | Removal date is announced | Still works, stronger outreach and deadline tracking |
| Removed | Contract is no longer available | Returns a planned error, redirect, or no route |
HTTP now has standard ways to communicate lifecycle hints.
RFC 9745 defines the Deprecation response header for signaling that a resource will be or has been deprecated. RFC 8594 defines the Sunset response header for signaling that a URI is likely to become unresponsive at a specified future time.
Example:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1798675200
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrate/customers-v2>; rel="deprecation"; type="text/html"
These headers are not a replacement for direct communication, changelogs, SDK updates, or partner outreach. They are useful runtime signals that make deprecation visible to clients, logs, proxies, and developer tools.
Official references:
Instrument Version Usage Before You Announce Dates
Sunset dates without usage telemetry are guesses.
At minimum, track:
| Signal | Why it matters |
|---|---|
| Requests by API version | Shows whether migration is actually happening |
| Requests by client application | Identifies who still needs outreach |
| Error rate by version | Catches migration bugs before clients fully switch |
| Endpoint usage inside old version | Finds old features that need specific migration docs |
| SDK version or user agent | Shows clients stuck on older generated code |
| Deprecation-header exposure | Confirms old-version traffic is seeing warnings |
Version telemetry should be attached to logs, metrics, traces, and support tooling. A support engineer should be able to answer:
- which version did this client call?
- when did they last call
v1? - which deprecated endpoints are still used?
- did they receive the deprecation and sunset headers?
- did their migration attempts produce errors?
Without that evidence, teams tend to remove versions because the calendar says they can, not because clients are actually ready.
A Release Gate For Breaking Changes
Before merging a breaking API change, require a versioning plan.
A practical release gate can look like this:
| Gate item | Pass condition |
|---|---|
| Compatibility classification | Change is labeled compatible, risky-compatible, or breaking |
| Contract diff | OpenAPI/schema/example diff is attached |
| Existing client impact | Known clients, SDKs, jobs, and partners are listed |
| New version surface | /v2, header version, or media type behavior is documented |
| Dual-running plan | Old and new versions can run at the same time |
| Migration guide | Field mapping, examples, errors, and rollback advice exist |
| Telemetry | Version and client usage can be measured |
| Deprecation communication | Docs, changelog, headers, SDK notes, and outreach are prepared |
| Removal criteria | Date, usage threshold, and rollback decision are defined |
| Tests | Current and previous versions have integration and contract coverage |
The gate should not block every change with ceremony.
It should block one specific failure mode: an incompatible change reaching production while everyone treats it like an ordinary refactor.
Common API Versioning Mistakes
Treating /v2 As The Whole Plan
A route prefix does not migrate clients.
The plan also needs documentation, examples, telemetry, outreach, SDK changes, contract tests, and a support window.
Changing v1 After Publishing v2
Publishing v2 does not make v1 disposable.
If v1 is still supported, its contract stays stable. Old clients should not be surprised by new v2 semantics leaking backward.
Removing Old Fields Too Early
If a field can remain in v1 without harming correctness, keep it until the support window ends.
Storage cleanup is rarely worth a client outage.
Making The New Version Depend On The Old Version
Avoid implementing v2 as a fragile wrapper around v1 behavior that must stay bug-compatible forever.
Share domain services where useful, but keep public representation mapping explicit per version.
Using Deprecation Warnings Without Migration Examples
"This endpoint is deprecated" is not enough.
The client needs to know what to call instead, how response fields map, what errors changed, which SDK version to use, and when the old version stops working.
Forgetting Database And Event Compatibility
An API version can be correct at the HTTP layer and still fail underneath.
If v2 requires a schema change, use the same compatibility discipline at the database layer. That is the overlap with Safe Database Migrations in Production.
If v2 changes emitted events, consumers need an event-contract migration too. Do not let the HTTP API migrate cleanly while downstream workers receive a surprise payload.
Practical Checklist
Before introducing a new API version:
- Define exactly which change is incompatible.
- Confirm the same goal cannot be reached through an additive
v1change. - Document the old-to-new field and behavior mapping.
- Run
v1andv2side by side. - Keep representation mapping explicit per version.
- Add contract tests for both versions.
- Add integration tests through the real request path.
- Track usage by version, endpoint, client, and SDK.
- Publish migration docs before asking clients to move.
- Use deprecation and sunset signals only with clear timelines.
- Remove the old version only after support windows and usage thresholds are satisfied.
The compact rule:
Version only when compatibility cannot be preserved, and once you version, treat migration as a product workflow.
Closing Thought
API versioning is how a team keeps improving an API after other people depend on it.
The visible version string is the smallest part. The valuable part is the promise behind it: old clients keep working, new clients get a better contract, and incompatible changes move through a measured migration instead of a surprise production break.