tech, developers, and the code underneath

issue 149· essay·

The API versioning decision

URL versions, header versions, or no versions. Each is a bet on how your consumers behave, and one of them is usually wrong.

Every API eventually needs to change in a way that breaks someone. How you handle that is a decision made once, early, that you live with for the life of the product.

the options#

URL versioning. /v1/users, /v2/users.

Explicit, visible, cacheable, easy to route. Anyone can see which version a request targets by reading the URL, which matters more than it sounds — for debugging, for logs, for support conversations.

The cost: it encourages big-bang versions. /v2 implies everything changed, so teams batch breaking changes into a major version, and consumers face a large migration instead of several small ones.

Header versioning. Accept: application/vnd.example.v2+json or a custom header.

Cleaner URLs, more RESTful in a purist sense, allows finer granularity.

The cost: invisible. You cannot paste a URL and know what it returns. Caching requires Vary handling that intermediaries get wrong. Debugging is harder. In practice most teams that choose this regret the invisibility.

Date-based versioning. API-Version: 2026-04-15.

The consumer pins to a date. Every breaking change gets a new date. Consumers upgrade by changing the date and reading the changelog for the intervening period.

This is what several of the better-run APIs do, and it is my recommendation. The advantage is that breaking changes can be small and frequent rather than large and rare, and each one is individually documented.

The cost: you must maintain compatibility shims for every version you support, which is real engineering work and requires discipline about how long you support them.

No versioning: only additive changes. Never break anything. Add fields, never remove or change them. Add endpoints, never change existing ones.

This works, is the least work for consumers, and is genuinely achievable for many APIs. The cost is accumulating cruft — deprecated fields that must be populated forever, endpoints nobody should use but that cannot be removed.

what actually counts as breaking#

Less obvious than it looks. Breaking:

  • Removing a field or an endpoint.
  • Renaming anything.
  • Changing a type — even integer to string for an ID, which people do.
  • Adding a required request field.
  • Changing validation to be stricter.
  • Changing an error code for an existing condition.
  • Changing default values.
  • Changing pagination behavior.
  • Changing the order of an array that consumers might depend on.

Non-breaking, usually:

  • Adding an optional request field.
  • Adding a response field. Usually. A consumer with strict schema validation that rejects unknown fields will break, which is why your documentation should state explicitly that clients must tolerate unknown fields.

That last one is worth putting in writing early. "Clients MUST ignore unrecognized fields" in your API documentation, from day one, converts a whole category of future breaking change into a non-breaking one.

the deprecation process that works#

  1. Announce, with a date, in the changelog and in the response headers.
Deprecation: Sat, 15 Apr 2026 00:00:00 GMT
Sunset: Wed, 15 Oct 2026 00:00:00 GMT
Link: <https://docs.example.com/migrate/v2>; rel="deprecation"
  1. Measure who is still using it. You need per-consumer usage metrics on the

deprecated path or you are guessing. This is the step everyone skips and it is what makes the rest possible.

  1. Contact the remaining users directly. Not a blog post. An email to the

specific integrations still calling it.

  1. Brownouts. Before the sunset, disable the endpoint for short windows —

an hour, then a day — with clear error messages. This surfaces the consumers who ignored every notice, while they still have time to fix it.

  1. Sunset, with a clear error explaining what to do.

The brownout step is the one that separates deprecations that work from deprecations that get postponed four times.

the internal API exception#

For an API consumed only inside your organization, versioning is usually over-engineering. You can find every consumer and change them.

Use a shared schema, break things when needed, coordinate the change, move on. The versioning machinery exists because you cannot coordinate with strangers, and if you can coordinate, skip it.

The failure mode is treating an internal API as external — building versioning, deprecation policies, and compatibility shims for three consumers you could have just updated.

the recommendation#

Date-based versioning for a public API, with a documented support window and a changelog that lists every change with its date.

Additive-only for an internal API, with a shared schema and the discipline to coordinate when you must break something.

And in both cases: state in your documentation, prominently, that clients must ignore unknown fields. It costs nothing now and saves a version bump later.

Dom, April 15, 2026

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →