What counts as a breaking change in a REST API

REST API versioning is the practice of letting existing clients keep a stable contract while the API changes underneath them. Teams use it because they rarely control every consumer. Mobile apps sit unupdated on phones, partner integrations ship on someone else's schedule, and internal services get deployed independently. A version identifier gives each client a promise: this shape and behavior will not change under you.

That promise only matters for breaking changes, meaning anything that causes a correctly written client to fail or misbehave without touching its code. Additive changes are safe only if clients ignore fields they don't recognize. A client that deserializes strictly and rejects unknown properties turns every addition into a break. Tolerant reading is part of the contract, so document it and enforce it in your SDKs.

Change Breaking? Why
Remove or rename a field Yes Clients reading the old name get null or crash
Change a type (string → integer) or meaning (cents → dollars) Yes Parsing fails, or worse, succeeds with wrong values
Tighten validation (new required field, shorter max length) Yes Previously valid requests now return 4xx
New error body format or different status code Yes Error handling and retry logic branch on these
Change pagination style, default page size or sort order Yes Clients silently miss or duplicate records
Add an optional response field No Tolerant readers ignore it
Add an optional request parameter or new endpoint No Existing calls are unaffected

The rest of this article compares URL versioning, header versioning and media type versioning. The more consequential decision, though, is how often you force a new version at all. Each version you publish is a contract you must run, test and eventually retire through API deprecation. An API that rarely breaks will cost little to maintain under any of the three schemes.

Who should use API versioning

Formal versioning pays off when the people who change the API cannot make every consumer upgrade at the same time. That describes these groups:

  • Public API providers. You don't know all your callers, and you can't make them deploy on your schedule.
  • Teams with partner or B2B integrations. Contracts and customer release cycles set the upgrade pace, not your roadmap.
  • Mobile backends. Old app builds stay installed for a long time, and you can't force every user to update.
  • Platform teams serving many internal services. This applies when consuming teams deploy independently and coordinated releases are impractical.

Versioning is usually unnecessary overhead in these cases:

  • The producer and consumer ship together. Examples are a backend-for-frontend that deploys with its web app, or two services owned by one team in a single deploy pipeline.
  • All consumers can be found and changed. If you can update every caller in one coordinated change, an explicit version adds machinery without protecting anyone.
  • The API is early and has no external users yet. Here it is cheaper to break freely and stabilize the contract before the first outside client arrives.

Pick a scheme before the first external client goes live. Adding versioning to an API that already has unversioned callers forces you to decide what those callers get by default, as covered in the section on choosing a default version.

How URL, header and media type versioning work

URL versioning

The version is part of the path: GET /v1/orders/42 and GET /v2/orders/42 are different URLs, so they behave as different resources everywhere. Routers can dispatch on the path prefix, access logs show the version without extra fields, docs split cleanly per version, and caches key on the URL with no extra configuration. The cost is that a resource's identity changes between versions, so stored links and client bookmarks must be rewritten on upgrade. A call is simply curl $API/v1/orders/42.

Header versioning

The client sends a custom header such as Api-Version: 2 on a stable URL: curl -H 'Api-Version: 2' $API/orders/42. Resource URLs never change, which keeps links valid across versions. Because the same URL can now return different bodies, the response must include Vary: Api-Version. Without it, a shared cache may serve a v1 body to a v2 client. Logging, routing and docs also need to read the header explicitly.

Media type versioning

The version lives in a vendor media type sent in Accept, for example Accept: application/vnd.acme.order.v2+json. The server echoes the selected type in the response's Content-Type, so the client can confirm which representation it received. If no supported representation matches, the server returns 406 Not Acceptable instead of guessing. This follows standard HTTP content negotiation, and responses need Vary: Accept for the same caching reason as header versioning.

GET /v2/orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json

{"id": 42, "status": "shipped"}

GET /orders/42 HTTP/1.1
Host: api.example.com
Api-Version: 2

HTTP/1.1 200 OK
Content-Type: application/json
Vary: Api-Version          # caches must key on this header too

{"id": 42, "status": "shipped"}

GET /orders/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.acme.order.v2+json

HTTP/1.1 200 OK
Content-Type: application/vnd.acme.order.v2+json   # echoes the chosen representation
Vary: Accept

{"id": 42, "status": "shipped"}

HTTP/1.1 406 Not Acceptable
Content-Type: application/json
Vary: Accept

{"error": "supported: application/vnd.acme.order.v1+json, application/vnd.acme.order.v2+json"}

The same order fetch under each scheme, as a curl command plus the raw HTTP exchange, including the Vary headers and a 406 response when media type negotiation fails.

Comparing the three schemes side by side

Criterion URL path Custom header Media type
Discoverability Visible in every link, log line and bug report Hidden; you learn it from docs Hidden inside Accept
Testing with curl or a browser Paste the URL Needs -H; browsers need an extension Needs a long, typo-prone Accept value
Cache and CDN behavior Distinct URLs, so default cache keys work Requires Vary or a custom cache key Requires Vary: Accept
Gateway and router support Plain path-prefix routing Header-match rules, widely supported Accept parsing, less common
Granularity Whole API Whole API or per resource Per resource
Documentation tooling One spec per version, renders cleanly Header parameter on each operation Several content types per response, rendered unevenly
Version visible in client code In the base URL Buried in client config In each request's Accept

URL versioning makes the version visible to every person and tool that touches a request. That visibility costs flexibility. A /v2 prefix versions the whole surface, so a breaking change to one endpoint forces a copy of every route. Those duplicated handlers then have to be maintained in parallel.

Header versioning keeps URLs stable, but it moves the version out of the cache key. If a CDN or shared proxy is not told to vary on that header, it can store a v2 response and serve it to v1 clients. This is cache poisoning, and it shows up as intermittent client failures that are hard to reproduce.

Media type versioning fits HTTP semantics best because the version describes the representation, not the resource. It also allows per-resource evolution. The downsides are that most developers find it unfamiliar and it is the most tedious to exercise by hand.

Hybrid schemes

Some teams combine schemes. A common pattern uses a coarse URL major version with a header or media type for finer changes. Hybrids recover some flexibility, but they double the number of places a version can be specified. You then have to define precedence rules, test every combination, and explain all of it in the docs. That added complexity rarely pays off unless the API has many independently evolving resources.

Alternatives to URL, header and media type versioning

The three schemes above are the most common, but they aren't the only options.

  • Query parameter versioning (GET /orders/42?version=2). This is easy to test in a browser, and caches key on the full URL by default. The downside is that the version is mixed in with real query parameters. Clients can also drop it when they build URLs, so a missing parameter needs a default policy, just like a missing header.
  • Hostname versioning (v2.api.example.com). Each version can live on separate infrastructure with its own DNS, TLS and scaling. That isolation helps when versions really are separate deployments. It is costly when they share one codebase, and it versions the whole API at once, like URL paths do.
  • No explicit versioning, evolve only. Commit to additive changes, tolerant readers and expand-and-contract migrations, as described in the next sections. This is the cheapest option to run. It holds up only if you can avoid semantic breaks, or can coordinate with every client when one is unavoidable.
  • Schema-level evolution in other API styles. GraphQL APIs usually expose one evolving schema. Fields are marked with the @deprecated directive and removed once usage stops, instead of publishing new endpoint versions. gRPC with Protocol Buffers depends on stable field numbers: new fields are added, removed field numbers are marked reserved so they are never reused, and breaking redesigns go into a new package name such as orders.v2. These styles don't remove the need for a breaking-change policy, but they push teams toward additive evolution.

Changing API style just to get a different versioning model is rarely worth it. If you already run REST, the useful lesson from these alternatives is the discipline: deprecate at field level, never reuse a removed name with a new meaning, and save new versions for real redesigns.

Choosing granularity, pinning and a default version

Granularity comes first. Major-only versions (v1, v2) suit APIs with few, infrequent breaking changes and clients you cannot coordinate with, because each version is a large, documented contract. Date-based versions, pinned per request or per client account, suit APIs that change often. Each small breaking change ships behind a new date, and a client moves forward only when it chooses to. The price is a compatibility layer that transforms responses for every pinned date still in use. That layer grows until you retire old dates.

Unversioned requests need an explicit policy, and every option has a cost:

  • Default to latest: new clients get the newest behavior, but any client that never sent a version breaks silently on your next release.
  • Default to oldest: nothing breaks, but you can never retire the oldest version, because you cannot tell which callers depend on it.
  • Reject the request: behavior is predictable, but every new integration has an extra step, and quick curl experiments fail.

A middle path is to pin unversioned callers to the version that was current when their API key was created.

Your client mix and infrastructure should also rule some schemes out. Avoid header or media type versioning if CDNs, shared caches or gateways in the path don't vary on that header, or if clients include browsers and low-code tools that make custom headers awkward. Avoid URL versioning when you want fine-grained, frequent changes, since minting new paths for each one fragments routing and documentation.

Hybrid pinning, such as a major version in the URL with an account-level date pin, is worth its extra docs and test matrix only when you have many long-lived integrations that upgrade on their own schedules.

Evolving an API without new versions

Each version costs engineering time for as long as it is supported, so the cheapest version is the one you never ship. Most changes can be additive: new endpoints, new optional request fields, new response fields. These break nobody, as long as clients follow the tolerant reader principle: ignore unknown fields, don't depend on field order, and treat unrecognized enum values as a generic fallback. State this in your API contract. Otherwise a client that fails on an unexpected field turns every addition into a breaking change.

New request fields should be optional, with a default that matches current behavior. For example, a sort parameter that defaults to today's ordering means existing callers notice nothing.

When a change really does replace something, use expand and contract. First add the new field alongside the old one and populate both. Then track usage of the old field. Remove it only when traffic is gone or the deprecation window has closed.

When a new version can't be avoided, keep one internal implementation and put thin per-version adapters at the edge. Forking the codebase means every fix has to be applied twice. As an example, internally an order stores amount_minor (integer cents) and currency. The v1 adapter converts this to the old decimal total field and drops currency, because v1 assumed a single currency.

Adapters fail in two cases:

  • Semantic changes. If v1 cannot represent an order in more than one currency, no adapter can express it correctly.
  • Accumulated complexity. Each adapter layer adds code that must be tested. Once adapters start containing business logic, retiring the old version is cheaper than keeping it.
// One internal model shared by every API version.
interface Order {
  id: string;
  amount_minor: number; // integer, smallest currency unit (e.g. cents)
  currency: string;     // ISO 4217 code, e.g. "USD", "EUR"
  status: "pending" | "paid" | "refunded";
}

// v1 contract: decimal total, currency implicitly USD.
interface OrderV1 {
  id: string;
  total: number;
  status: string;
}

export class NotRepresentableError extends Error {}

// Thin adapter: pure field mapping, no business logic.
export function toV1(order: Order): OrderV1 {
  // Semantic gap: v1 cannot say "this total is in EUR".
  // Fail loudly rather than return a number that looks valid but is wrong.
  if (order.currency !== "USD") {
    throw new NotRepresentableError(
      `order ${order.id} is in ${order.currency}; v1 cannot represent it`
    );
  }
  return {
    id: order.id,
    total: order.amount_minor / 100, // USD has 2 minor units
    status: order.status,
  };
}

// v2 exposes the internal shape; additive fields flow through untouched.
export const toV2 = (order: Order): Order => order;

const adapters = { v1: toV1, v2: toV2 } as const;

// Handlers load one internal Order and choose a representation only at the edge.
export const render = (version: keyof typeof adapters, order: Order) =>
  adapters[version](order);

A single internal Order model rendered through thin per-version adapters, where the v1 adapter converts integer cents to a decimal total, drops currency, and raises an error for non-USD orders that v1 cannot represent.

An API deprecation workflow that gets versions retired

Retiring a version is mostly a communication and measurement problem. A workable sequence:

  1. Announce a fixed timeline. Publish the deprecation date and the removal date together, with enough time between them for clients on slow release cycles. A date clients can plan around is more useful than "soon."
  2. Signal it in every response. Add a Deprecation header (a structured date such as @1767225599), a Sunset header with the removal time as an HTTP-date (Sunset: Thu, 31 Dec 2026 23:59:59 GMT), and a Link header with rel="deprecation" pointing to the migration guide. Mirror this in the changelog and mark the affected endpoints in the reference docs.
  3. Measure before you remove anything. Log traffic by version and by client identity (API key, OAuth client, or user agent). Aggregate request counts hide the fact that most residual traffic often comes from a few integrations.
  4. Contact remaining callers directly. Send email to the owners of those keys, or open tickets with them. Most clients never read response headers, and many never read changelogs, so passive signals will not reach everyone.
  5. Schedule brownouts. Return errors for short, pre-announced windows that get longer each time. A failing request is often the first signal a forgotten integration's owner notices.
  6. Shut down and return a clear error. Use 410 Gone with a body that links to the migration guide.

Expect some long-tail clients to break anyway, such as abandoned scripts or devices that cannot be updated. Decide in advance how much residual traffic you will accept at the cutoff, so the removal date does not keep moving.

GET /v1/orders/8812 HTTP/1.1
Host: api.example.com
Authorization: Bearer <redacted>

HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1767225599
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <>; rel="deprecation"; type="text/html"
Link: <>; rel="sunset"; type="text/html"

{"id": 8812, "status": "shipped"}

HTTP/1.1 410 Gone
Content-Type: application/problem+json

{
  "type": "",
  "title": "API v1 has been retired",
  "status": 410,
  "detail": "Use /v2/orders/8812. Migration guide: "
}

A v1 response carrying Deprecation, Sunset and Link headers, followed by the 410 Gone response the same request receives after shutdown or during a brownout.

Operational setup and impact on development cost

Every request should carry its resolved version, meaning the version actually served after defaults and pins are applied, not just what the client sent. Put it on log lines, metric labels and trace spans. Without it you cannot tell who still depends on an old version, and deprecation becomes guesswork.

client ──> CDN/cache ──> gateway ──> version resolver ──> adapter v1 ─┐
           (Vary on                  (header, path,      adapter v2 ─┼──> core service
           version header)           or media type)      adapter v3 ─┘

Route at the gateway only when versions are separate deployments. If a single core service sits behind thin adapters, resolve the version in application code, where it can be tested. In both cases, configure caches to vary on the version header or media type. Otherwise a cache can serve a v2 body to a v1 client.

Run contract tests for every supported version on every build. An old version rarely breaks loudly. It drifts when a core change slips through an adapter unnoticed.

Illustrative estimate (assumed numbers, not a benchmark)

Quarterly cost = N × (T + D + S + O), where N is the number of supported versions.

Assume these per-version costs in engineer-days per quarter:

  • T (tests): 2
  • D (docs): 1
  • S (security backports): 1.5
  • O (on-call): 1.5

With N = 4, that is 24 days per quarter, or 96 per year. Assume a one-time migration that retires two versions costs 50 days. It saves 12 days per quarter and pays back in about a year. After that, the savings are permanent, while keeping old versions alive keeps costing you indefinitely.

+--------+    +-----------------+    +-----------------------+
| client |--->| CDN / cache     |--->| gateway               |
+--------+    | Vary on version |    | routes only if each   |
              | header / media  |    | version is a separate |
              | type            |    | deployment            |
              +-----------------+    +-----------+-----------+
                                                 |
                                                 v
+-------------------------+    +-----------------------------------+
| logs / metrics / traces |    | version resolver (app code)       |
| tag: resolved version   |<---| input: path | header | media type |
| (who still uses v1?)    |    | applies defaults and client pins  |
+-------------------------+    | => RESOLVED version               |
                               +-----------------+-----------------+
                                                 |
                                    +------------+------------+
                                    v            v            v
+-------------------------+    +---------+  +---------+  +---------+
| contract tests run for  |    | adapter |  | adapter |  | adapter |
| EVERY supported version |--->|   v1    |  |   v2    |  |   v3    |
| on every build (drift)  |    +----+----+  +----+----+  +----+----+
+-------------------------+         |            |            |
                                    +------------+------------+
                                                 v
                               +-----------------------------------+
                               | core service (single codebase)    |
                               +-----------------------------------+

A request passes through a cache that varies on the version and through the gateway to an in-app resolver, which applies defaults and pins, tags telemetry with the resolved version, and dispatches to a per-version adapter in front of one core service, with every adapter covered by contract tests.

How versioning saves engineering time

Versioning adds ongoing cost, as the estimate above shows. It saves time by removing two more expensive kinds of work: coordinated releases and incident response caused by unplanned breaking changes.

  • No lockstep releases. Without versions, a breaking change means finding every consumer, getting each one to change and deploy, and then releasing everything together. With a versioned contract, the API team ships when ready and each consumer migrates on its own schedule. The coordination happens through a deprecation timeline, not through meetings and synchronized deploys.
  • Fewer production incidents from contract drift. A clear breaking-change policy, like the table at the top of this article, plus contract tests for each supported version, catches accidental breaks in CI. Without them, the same break is often found by a client in production and debugged across team boundaries.
  • Faster debugging. When the resolved version is tagged in logs, metrics and traces, a bug report can be narrowed to one adapter and one contract right away, without first working out which behavior the caller expected.
  • Less duplicated code. A single core service with thin adapters means a bug fix or security patch is written once, not once per forked codebase.
  • Predictable retirement work. A standard deprecation workflow turns each version removal into a repeatable checklist instead of a one-off negotiation.

These savings hold only while the number of live versions stays small. Most of the time saved comes from the practices around the version identifier rather than from the identifier itself: breaking rarely, evolving additively, keeping adapters thin and retiring old versions on a fixed schedule. A team that publishes a new version for every change and never retires any will spend more time than it saves, whichever of URL, header or media type versioning it chose.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to rest api versioning, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.