May 15, 2026

API versioning strategies that don't turn into a mess

backendapi
API versioning cover graphic for erkshitiz.com.np

Every API eventually needs to change in a way that breaks someone. The question is never whether you’ll need versioning, it’s which flavor of it you can live with once you have real consumers depending on you. I’ve now shipped all three of the common approaches at different jobs, and each one taught me something the docs don’t really tell you upfront.

URL path versioning (/v1/users, /v2/users) is the one most public APIs use, and for good reason. It’s visible, it’s cacheable by anything that caches on URL, and it’s trivial to debug because the version is sitting right there in the access log. The downside is that it invites you to fork entire route trees, and once you have a /v1 and a /v2 living side by side, you own both until every client migrates off the old one, which in practice is years, not months. Here’s roughly how we split the routing in Go:

mux := http.NewServeMux()

v1 := http.NewServeMux()
v1.HandleFunc("GET /users/{id}", v1GetUser)

v2 := http.NewServeMux()
v2.HandleFunc("GET /users/{id}", v2GetUser)

mux.Handle("/v1/", http.StripPrefix("/v1", v1))
mux.Handle("/v2/", http.StripPrefix("/v2", v2))

Header-based versioning, either a custom header like Api-Version: 2 or an Accept media type like application/vnd.myapi.v2+json, keeps the URL clean and appeals to people who like their resource paths to represent one canonical thing forever. In practice I’ve found it’s worse for discoverability: nobody can tell what version a request hit just by looking at a URL someone pasted into Slack, and caching proxies that key on URL alone will happily serve a v1 response to a client that asked for v2 unless you’re careful to vary the cache key on that header. It also makes debugging production issues slower, because now you need the full request, not just the path, to know what code ran.

The third option, additive-only versioning, means you never version at all. You only add new fields and new endpoints, you never remove or repurpose an existing field, and anything that would be a breaking change becomes a new field or a new endpoint instead. This works surprisingly well for internal APIs where you control every consumer and can grep the codebase for who’s calling what. It falls apart the moment you have external consumers you can’t audit, because you can’t prove a field is truly unused, so “additive-only” quietly becomes “we never clean anything up” and the schema accretes cruft forever.

A concrete example of the difference: adding an optional email_verified boolean to a user response is non-breaking, any client that ignores unknown fields keeps working. Renaming created_at to createdAt, or changing a field from a string to a nested object, is breaking, and it will silently corrupt any client that was doing naive JSON parsing without ignoring unknown types.

// non-breaking: existing clients ignore the new field
{ "id": "u_1", "name": "Kshitiz", "email_verified": true }

// breaking: existing clients expect created_at to be a string
{ "id": "u_1", "name": "Kshitiz", "createdAt": { "seconds": 1723000000 } }

For a public API with external consumers I’d default to URL path versioning every time. It’s the option that fails loudly instead of silently, it’s the easiest to explain to a third-party developer in one sentence, and it plays nicely with API gateways, rate limiters, and caching layers that were all built assuming the URL is the unit of identity. Header versioning sounds cleaner on paper but I’ve never seen it pay off enough to offset the debugging tax. For internal-only services, additive-only is genuinely the least amount of ceremony, as long as someone is disciplined about actually deprecating unused fields instead of letting them pile up.

If you asked me to pick one thing to get right before you ship a public v1, it’s this: decide now how long you’re willing to support v1 once v2 ships, write that number down somewhere your team can see it, and hold yourself to it. The versioning scheme itself matters less than having an actual deprecation policy, because the real cost was never picking URL versioning over headers, it was the two-year-old v1 endpoint nobody wants to touch because one customer still depends on it and nobody remembers who that customer is anymore.