Versioning

URI, header and media-type versioning, what a breaking change is, and the deprecation process that keeps clients from being surprised.

6 min read🔗 REST API Engineering

Versioning is the subject people reach for first and need least. Most APIs that carry a /v2 got there by making a change they did not have to make, and the version then has to be maintained forever by a team that wanted to rename one field.

So this lesson is in two halves: what actually breaks a client, and — only once that is clear — how to version when you genuinely must.

What breaks a client, and what does not

A client breaks when something it relied on changes. That is narrower than it sounds, and the two lists are worth knowing precisely.

Compatible — safe to ship without a version:

  • Adding a new field to a response. A client that ignores unknown fields is unaffected, and any sensible parser does.
  • Adding a new optional request field.
  • Adding a whole new endpoint.
  • Adding a new value to an enum-like field — if clients were told to expect that.
  • Relaxing a validation rule: accepting something you used to reject.

Breaking — needs a version, or a migration plan:

  • Removing or renaming a field, in either direction.
  • Changing a field's type — "1999" to 1999 breaks every strictly typed client.
  • Making an optional request field required.
  • Tightening validation: rejecting something you used to accept.
  • Changing a status code for the same condition.
  • Changing the meaning of a field while keeping its name and type. This is the worst one, because nothing detects it — not a schema diff, not a contract test — and the client keeps working while doing the wrong thing.

That last item deserves its own sentence. Changing total from including tax to excluding tax is invisible to every automated check you have and wrong in every client that reads it.

The change you cannot make

Some changes have no compatible form. Splitting name into firstName and lastName cannot be done by addition alone, because the old field has to go eventually, and "eventually" is where the work is.

The pattern that works is the one from the migrations lesson, applied to a contract instead of a schema: expand, migrate, contract.

  1. Expand. Add firstName and lastName beside name. Populate all three. No client is affected.
  2. Migrate. Document name as deprecated, announce it, and — this is the step people skip — measure who is still sending or reading it. Log usage per client; you cannot remove what you cannot see.
  3. Contract. When the number reaches zero, or the remaining callers have agreed a date, remove it.

That takes months, and the months are the point: they are what a version number would have cost you anyway, without the second codebase.

Three ways to say which version

When you do need one, there are three places to put it, and the argument between them is smaller than the internet suggests.

In the URI/v1/orders, /v2/orders:

  • Visible in a log, a browser and a curl command; trivially routable at a gateway.
  • Purists object that the resource has not changed, only its representation, so it does not belong in the identifier.
  • The common choice, for the practical reasons above.

In a headerX-API-Version: 2:

  • Keeps the URL stable.
  • Invisible. A developer pasting a URL gets an unversioned default and different behaviour from their colleague, and nothing in the request makes that obvious.

In the media typeAccept: application/vnd.example.order.v2+json:

  • Formally the most correct: content negotiation is exactly the mechanism for "same resource, different representation".
  • Almost nobody does it, tooling handles it poorly, and it makes a browser useless for exploration.

Pick URI versioning unless you have a specific reason not to, version the whole API rather than per endpoint — per-endpoint versions produce a matrix nobody can reason about — and use a major number only. v1.2 implies a compatible change, and compatible changes do not need a version.

Announcing a removal

A deprecation nobody is told about is an outage with a longer fuse. The mechanics are standardised and cost almost nothing:

plaintext
Deprecation: Sun, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 Feb 2027 00:00:00 GMT
Link: <https://api.example.com/docs/migrating-to-v2>; rel="deprecation"

Deprecation says when it became deprecated; Sunset (RFC 8594) says when it stops working; the Link says where to read about it. A client's own monitoring can pick these up, which is the entire reason to send them as headers rather than only writing a blog post.

Alongside that: log which clients use the deprecated path, and tell them individually. "We emailed the list" is not a migration plan when one integration is a customer's billing system.

Running two versions

If you ship a v2, decide early how much of the code is duplicated, because the honest answer is "as little as possible":

  • One service, two sets of DTOs and controllers, one domain underneath. The versions differ in their contract, not in what the system does. This is almost always right.
  • Two deployments is a last resort — double the operational surface, and every bug fix needs porting.

And set an end date when you launch, not when you get tired. An API version with no announced sunset is one that will still be running in five years, because nothing forces the conversation.

Progress is saved on this device and to your account when signed in.