Back to Writing
DevOps 2026-06-01

API Versioning Strategies That Don't Suck

URI versioning, header versioning, query-param versioning, and the 'evolve don't version' approach each have real trade-offs. Here's when each makes sense, how to deprecate cleanly, and what to do when consumers won't upgrade.


API versioning is one of those topics where everyone has an opinion and most codebases have a mix of three strategies applied inconsistently. The problem isn’t choosing a strategy. The problem is building any versioning system on the assumption that consumers will upgrade.

They won’t.

They’ll pin to v1, ignore your deprecation notices, and your breaking change in v2 means nothing because the caller is still sending Accept: application/vnd.yourapp.v1+json from a client that was deployed in 2019.


The Four Approaches

URI versioning (/api/v1/users, /api/v2/users):

The most common and most criticized. It’s explicit in logs, easy to route, and trivial to test. The downside is semantic: the resource isn’t versioned, the representation is. /api/v1/users/42 returns a different shape in v2, but the user is the same.

Good for: public APIs with external consumers. Logs clearly show which version was called. Caching is straightforward.

Bad for: rapid iteration. Every version adds endpoints you’re stuck maintaining. Teams accumulate dead v1 endpoints for years.

Header versioning (Accept: application/vnd.yourapp.v1+json):

Cleaner semantically — the resource is stable, the representation changes. Keeps URLs clean. The problem: it’s invisible in logs unless you explicitly log the Accept header. It’s hard to test from a browser. And it makes API exploration painful when every tool needs custom headers.

Good for: internal APIs where consumers understand HTTP semantics.

Bad for: public APIs used by non-experts. Harder to document, harder to debug.

Query-param versioning (/api/users?version=1):

Easiest for consumers — just add a parameter. Also the easiest to cache incorrectly (proxies don’t know that ?version=1 and ?version=2 are different resources without careful configuration). And it encourages every client to omit the version entirely and get the default — which will suddenly change when you bump it.

Good for: transitional APIs during a migration.

Bad for: anything with caching infrastructure upstream.

Evolve, don’t version (no explicit version, make backward-compatible changes):

Add fields but don’t remove them. Accept old input and new input. Transform internally. Never break a caller.

Good for: internal APIs where you control all consumers and can update them.

Bad for: any API where consumers can’t update on your schedule.


The Deprecation Contract

Whatever strategy you choose, the deprecation contract must be explicit and automated:

  1. Add a Sunset header on every deprecated version response. The date should be at least 6 months from the deprecation announcement.
  2. Log every deprecated call with consumer identity. You need to know who is still calling v1.
  3. Add a Deprecation header with a URL to migration docs: Deprecation: https://docs.yourapp.com/migrations/v1-to-v2
  4. Monitor, don’t assume. When the sunset date arrives, check your logs. If someone is still calling v1, either they didn’t get the message or they can’t upgrade. Either way, breaking them is a product decision, not a technical one.

The minimum viable deprecation policy:

Announce → Add Deprecation header → Wait 6 months → Add Sunset header →
Wait 3 months → Log every call → Contact known consumers →
Return 410 Gone on the sunset date

Never skip the consumer-contact step. An API that returns 410 breaks a production system. The person who wrote the consumer may have left the company. The team maintaining it may not know it exists. Contact them in-person or via a shared channel, not just by email.


The Internal API Case: No Versioning

For internal APIs where you control every consumer, the right answer is often no versioning at all. Evolve the API backward-compatibly. Add fields. Deprecate old fields in documentation but don’t remove them for at least a year.

The cost of maintaining an unused field in a JSON response is zero. The cost of breaking a consumer that you didn’t know existed is an incident post-mortem.

When you absolutely must break backward compatibility (rarely, usually for security or performance), do it as a coordinated flag day: update all consumers in the same deployment window. This requires knowing every consumer and having CI pipelines for all of them. If you don’t have that, you need explicit versioning.


The Versioning Reality

The best API versioning strategy is the one your consumers can actually follow. For external APIs, URI versioning with clear deprecation headers and a 9-month minimum migration window. For internal APIs, evolve don’t version, with one coordinated break per year maximum.

And always: your sunset date means nothing if you haven’t verified that every consumer has stopped calling the old version. Automation that checks logs for deprecated calls and alerts when the count hits zero — that’s how you know the version is actually dead.


Related: This same deprecation discipline applies to Calibre’s MCP tools — deprecated tools are marked with a @deprecated JSDoc tag, maintained for at least one release cycle, and never removed without verifying no workspace configuration references them.