API Versioning Without Breaking Clients

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, or Accept headers?

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:

  1. keep the old version stable
  2. publish the new version
  3. document the mapping
  4. instrument usage by client and version
  5. migrate clients deliberately
  6. deprecate the old version
  7. 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.

ChangeUsually breaking?Why clients break
Remove a response fieldYesDeserializers, UI code, mappings, and database sync jobs fail
Rename name to fullNameYesA rename is remove plus add from the client's point of view
Change status from string to objectYesTyped clients and JSON parsers expect the old shape
Add a required request fieldYesOld clients do not send it
Tighten validation for previously accepted inputUsuallyOld clients may submit values that used to work
Change default sort order or pagination sizeOftenClients may assume stable ordering, counts, or "all rows"
Add a response fieldUsually noRobust clients should ignore unknown fields
Add a new optional request parameter with the same defaultUsually noOld clients get the same behavior
Add an enum value to a responseRiskySome clients treat enums as closed lists
Change error status or error body shapeOftenRetry, validation, and support tooling often parse error details
Change meaning without changing schemaOftenSemantic 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.

StrategyExampleStrengthCost
Path versioning/v1/ordersEasy to see, route, cache, document, and debugCan encourage duplicated controllers
Query parameter/orders?api-version=2026-05-13Explicit and easy for manual callsCan be awkward for cache keys, links, and route organization
Custom headerAPI-Version: 2Keeps URLs stable and supports per-client negotiationEasier to miss in proxies, logs, SDKs, and manual debugging
Media typeAccept: application/vnd.example.v2+jsonTies version to representation negotiationMore complex for clients and harder for many teams to operate
Date-based versionAPI-Version: 2026-05-13Good for per-client compatibility snapshotsRequires 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:

  1. A pull request changes an OpenAPI schema, response fixture, error body, or example.
  2. The contract gate detects an incompatible change.
  3. The author either makes the change backward-compatible or attaches a versioning plan.
  4. 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:

StateClient meaningServer behavior
SupportedSafe for current and new integrationsWorks normally, receives compatible improvements
DeprecatedExisting clients may continue, new clients avoid itWorks normally, emits warnings and migration links
Sunset-readyRemoval date is announcedStill works, stronger outreach and deadline tracking
RemovedContract is no longer availableReturns 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:

SignalWhy it matters
Requests by API versionShows whether migration is actually happening
Requests by client applicationIdentifies who still needs outreach
Error rate by versionCatches migration bugs before clients fully switch
Endpoint usage inside old versionFinds old features that need specific migration docs
SDK version or user agentShows clients stuck on older generated code
Deprecation-header exposureConfirms 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 itemPass condition
Compatibility classificationChange is labeled compatible, risky-compatible, or breaking
Contract diffOpenAPI/schema/example diff is attached
Existing client impactKnown clients, SDKs, jobs, and partners are listed
New version surface/v2, header version, or media type behavior is documented
Dual-running planOld and new versions can run at the same time
Migration guideField mapping, examples, errors, and rollback advice exist
TelemetryVersion and client usage can be measured
Deprecation communicationDocs, changelog, headers, SDK notes, and outreach are prepared
Removal criteriaDate, usage threshold, and rollback decision are defined
TestsCurrent 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 v1 change.
  • Document the old-to-new field and behavior mapping.
  • Run v1 and v2 side 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.