API Versioning: Pick One and Stop Overthinking It

| 4 min read |
api versioning rest architecture

API versioning is a maintenance commitment, not a design exercise. URL paths win for public APIs, headers for internal ones. The real discipline is not versioning -- it's avoiding breaking changes in the first place.

Let me save you some time. If you’re building a public API, put the version in the URL path. /api/v1/users. Done. If it’s internal, use a header. That’s the answer for 90% of cases. The rest of this post is about the discipline around versioning, which is where the actual work lives.

Most changes shouldn’t need a new version

The best versioning strategy is needing fewer versions. Design for additive growth. Add fields, add endpoints, add optional parameters. As long as clients follow the tolerant reader pattern – ignore what you don’t understand, don’t fail on extra fields – additive changes are safe.

A change is breaking when it invalidates something a client is already doing:

  • Removing or renaming a field or endpoint.
  • Changing a field’s type (string to integer, for example).
  • Making an optional parameter required.
  • Tightening validation so previously accepted requests get rejected.

If you’re doing one of these, you need a version. If you aren’t, you probably don’t.

At a real-time messaging company, the API surface was massive and had clients in every language. The discipline was simple: additive changes ship immediately, breaking changes go through a versioning and deprecation cycle. This kept the version count low while still allowing the API to evolve.

The strategies, briefly

URL path versioningGET /api/v1/users/123. Clear, explicit, easy to route, cache-friendly. The downside is versioned URLs spread through documentation and client code. But for public APIs, clarity is worth more than elegance. This is the default choice for a reason.

Header versioningX-API-Version: 2 or content negotiation via Accept headers. Keeps URLs clean, routing is stable. The downside: less visible, harder to test casually (you can’t just paste a URL into a browser), and caches need Vary headers to avoid mixing versions. Good for internal APIs where clients are controlled and headers are already managed.

Query parameter versioningGET /api/users/123?version=2. Easy to test, simple to implement. Pollutes the query string and can confuse caches. I’ve mostly seen this used as a transitional mechanism, not a permanent strategy.

Media type versioningAccept: application/vnd.example.user.v2+json. Technically pure. Practically annoying. Tooling support is inconsistent and it confuses developers who aren’t deeply familiar with content negotiation. Unless you already have a strong content negotiation setup, skip this.

The actual hard part: deprecation

Choosing a versioning strategy takes an afternoon. Managing the lifecycle of multiple versions takes discipline over months or years.

When you introduce v2, here is what you’re signing up for:

  1. Running v1 and v2 in parallel for a defined period. Define it. Write it down. “We support the previous version for six months after the new version launches.” Whatever your number is, commit to it.

  2. Publishing a migration guide. Not “v2 has improvements.” A concrete document: “This field moved here, this endpoint was replaced by this one, here is a before-and-after example.” At the fintech startup, we learned that the quality of the migration guide directly determined how quickly clients moved. Bad guide means slow migration means supporting two versions forever.

  3. Communicating deprecation clearly. The Sunset header (RFC 8594) is useful – it tells clients programmatically when a version will be retired. Add deprecation warnings in responses. Put sunset dates on your developer portal. Make it impossible for someone to miss.

  4. Actually removing the old version. This is the step most teams skip. They launch v2, send deprecation notices, and then never kill v1 because someone is still using it. Set a sunset date. Communicate it early. Follow through. Supporting zombie API versions forever is a maintenance tax that never goes away.

Design to avoid versioning in the first place

A few schema design choices can dramatically reduce how often you need a new version:

  • Wrap arrays in objects. {"users": [...], "total": 100} instead of bare [...]. This lets you add metadata later without breaking the shape.
  • Use objects for complex values. An address as {"street": "...", "city": "..."} is extensible. A single string field isn’t.
  • Return enums as strings, not integers. Easier to extend, easier to debug, and clients can safely ignore values they don’t recognize.
  • Don’t rely on array ordering for semantics. If order matters, make it explicit.

Test your contracts

Each API version is a contract with your clients. Test it like one.

Contract tests should verify: required fields are present, types are correct, and key behaviors are stable. Run them against every version you support. When you add a new field, verify that a test client using the old version still works. When you deprecate a version, keep the tests running until the version is actually removed.

Versioning is a maintenance commitment. The goal isn’t to have a clever versioning scheme. The goal is to keep clients working while the API evolves. Keep versions rare, design for additive change, communicate deprecations early, and follow through on sunsets. That’s the whole strategy.