Every public API eventually needs to change in a breaking way. Versioning strategies exist to keep old clients working while new ones get new features. The choice seems minor at the start — it isn’t. A two-year-old API with the wrong versioning strategy is expensive to fix.

The four common strategies

URL versioning. /v1/users, /v2/users. Obvious, cache-friendly, easy to debug. Clients pin to a version by URL.

Header versioning. Accept-Version: 2 or custom header. URL stays clean, but cacheability and discoverability suffer.

Media-type versioning. Accept: application/vnd.company.v2+json. Most “RESTful” in the purist sense, least practical. Rare outside spec purists.

Query parameter versioning. /users?version=2. Works but looks ad-hoc, breaks CDN caching, often confuses clients.

The practical winner — URL versioning

For almost every real system, URL versioning is the right default:

  • Visible — a glance at logs tells you which version a client is using
  • Testable — curl works without special headers
  • Routable — gateways and load balancers can route based on URL
  • Cacheable — CDNs don’t need to know about headers
  • Simple — client SDKs just build URLs

The purist objection (“it’s not really a different resource, it’s a different representation”) is philosophically correct and practically irrelevant.

When to bump the version

Not for every change. Backward-compatible additions — new optional fields, new endpoints, new enum values — don’t need a version bump. Only bump on breaking changes.

Breaking: remove a field, change a field’s type, rename, change required/optional, change default behavior, remove an endpoint.

Not breaking: add optional field, add endpoint, add enum value (with care), make required field optional.

Following Postel’s law helps — be conservative in what you send, liberal in what you accept. Clients that tolerate unknown fields give you room to add without versioning.

Supporting multiple versions

The honest cost of versioning: you’ll run multiple versions in production.

Options:

Dual implementation. v1 and v2 are separate code paths. Clean but double the maintenance. Fine for 2-3 major versions; painful past that.

Adapter layer. v2 is the current code; v1 is a translation layer that converts v1 requests/responses to v2. One source of truth, extra complexity per legacy version.

Library sharing. Core logic in a shared module; thin per-version wrappers. Middle ground.

I’ve had best luck with the adapter approach — it forces explicit thinking about what actually changed.

Deprecation discipline

A version you can’t remove is a version that runs forever. Retirement process:

  1. Announce deprecation in release notes, support channels
  2. Add Deprecation: ... and Sunset: ... response headers per RFC 8594
  3. Log and report usage by version
  4. Reach out to the top consumers; help them migrate
  5. Set a sunset date (usually 12 months for public APIs)
  6. After sunset, return 410 Gone

The hardest part is political — someone always has an outdated integration. Firm dates with advance notice work better than rolling deprecations that never actually end.

Internal APIs are different

Internal services talking to each other should not version. Use contract testing and coordinated deploys. Versioning is for external clients you can’t control. Adding versioning to internal APIs adds maintenance without benefit.

For internal, the workflow is:

  • Change producer and all consumers in the same PR (if possible)
  • If not, producer adds new field/behavior non-breakingly first, consumers adopt, then producer removes old
  • Contract tests (Pact, Spring Cloud Contract) catch incompatibilities before deploy

Common mistakes

Versioning everything separately. Every endpoint independently versioned. Becomes unmanageable. Version at the API level, not per endpoint.

Never removing old versions. v1 is still running five years later because “someone might use it”. Set sunset policy up front.

Changing semantics without bumping version. “This field now includes weekends” — even if the type is the same. Consumers break in subtle ways. If behavior changes meaningfully, bump the version.

No usage tracking. You can’t deprecate what you can’t measure. Log version per request from day one.

Closing note

Versioning is a contract with your users. The best strategy is the one that’s easy to implement, easy to monitor, and easy to deprecate. URL versioning fits all three. Combined with a clear deprecation process and discipline about what actually counts as breaking, it gives you room to evolve without stranding consumers.