API Versioning Strategies (and How to Avoid Breaking Clients)

Once someone builds against your API, you own a promise. Their code reads the fields you return, calls the routes you documented, and assumes the shapes stay put. Change any of that and their integration breaks on your schedule, not theirs. Versioning is how you make changes without pulling the rug out from under people who already trust your contract.

This is one of the harder tradeoffs in API design best practices, because the right answer often is to not version at all.

Breaking vs non-breaking changes #

Before you pick a strategy, get precise about what actually forces a version bump. Not every change does.

Safe (non-breaking): Adding a new field to a response, adding a new optional request parameter, adding a whole new endpoint, or adding a new value to an enum that clients already handle gracefully. Well-written clients ignore fields they don't recognize, so additive changes slide in quietly.

Breaking: Removing a field, renaming a field, changing a field's type (string to number), changing its semantics (a status that used to mean one thing now means another), making an optional parameter required, or tightening validation so previously valid requests start failing. Anything a client currently depends on that you take away or alter counts.

The dividing line is dependency. If existing code can keep working untouched, you are safe. If it cannot, you have a breaking change and you need a plan.

The versioning approaches #

There are four common places to put a version, plus the option of not versioning explicitly.

URI path (/v1/users). The version lives in the URL. Easy to read, easy to route, trivial to test in a browser or curl. Everyone can see exactly which version they are hitting. The downside is that it treats the version as part of the resource identity, which purists dislike, and it can push you toward duplicating routes. It is also the most widely understood approach, which counts for a lot.

Query parameter (/users?version=2). The version rides along as a query string. Simple to add and optional to omit, so you can default to the latest or to v1. The tradeoff is that versions get scattered across query strings, caching gets fiddly, and it is easy to forget the parameter and silently get the wrong behavior.

Custom header (X-API-Version: 2). Keeps URLs clean and separates versioning from the resource path. Good for teams that want the canonical URL to stay stable. The cost is discoverability: you cannot see the version in a link, headers are easy to omit, and debugging means inspecting requests rather than reading a URL.

Media-type / content negotiation (Accept: application/vnd.api.v2+json). The most RESTful option. The client asks for a representation and the server serves the matching one, so the resource URL never changes. It is also the least approachable. Most developers have never set a custom Accept header, tooling support is uneven, and onboarding gets slower.

No versioning / additive-only evolution. The ideal. If you commit to only ever adding fields and endpoints, never removing or changing them, clients never break and you never version. This demands discipline and a bit of upfront design, but it sidesteps the entire problem. Most changes can be additive if you plan for it.

Deprecation policy #

Supporting multiple versions is only half the job. The other half is retiring old ones without ambushing anyone.

Announce deprecations in the response itself using the standard Deprecation and Sunset headers, so clients get a machine-readable warning long before anything stops working. Pair that with a documented timeline, typically 6 to 12 months for a public API, and direct communication through changelogs, email, and dashboard notices.

Here is a URI-versioned route signaling that it is on the way out:

http.http

GET /v1/users/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 31 Jan 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users/42>; rel="successor-version"

The Sunset header names the exact date the endpoint stops responding, and the Link header points to the replacement. Clients monitoring their logs see this coming and migrate on their own terms.

While two versions run in parallel, keep the shared logic in one place and let the version layer handle only translation. The more you fork whole code paths per version, the more your maintenance cost multiplies. Aim to support at most two live versions at once.

How GraphQL handles it #

GraphQL sidesteps URL versioning almost entirely. Because clients request exactly the fields they need, adding a field never affects anyone who did not ask for it. That makes additive evolution the default path rather than a discipline you have to enforce.

Instead of new versions, GraphQL deprecates individual fields with a directive:

graphql.graphql

type User {
fullName: String
name: String @deprecated(reason: "Use fullName")
}

Tooling surfaces the deprecation warning to consumers, you watch usage of the old field drop toward zero, and eventually you remove it. The schema evolves continuously rather than jumping between numbered snapshots.

How to choose #

Match the mechanism to your audience. A public REST API with unknown clients needs the most visible, hardest-to-get-wrong option. An internal API between two teams you control can lean on whatever is cheapest to coordinate, and might get away with additive-only evolution and no explicit version at all. If you are already on GraphQL, use field deprecation and skip versioning as a concept.

Weigh discoverability, tooling support, and how much you trust clients to read documentation. The more anonymous your consumers, the more the version needs to be impossible to miss.

The call we'd make #

For a public REST API, use URI path versioning (/v1/). It is the most obvious, the easiest to test, and the least likely to trip up a client who skimmed the docs. The theoretical purity of content negotiation rarely pays for its onboarding cost.

But the real win is upstream: design for additive change so most updates never need a version bump at all. Add fields, do not remove them. Add endpoints, do not repurpose them. When you genuinely must break the contract, cut a new URI version, ship Deprecation and Sunset headers, give people a year, and retire the old one on schedule. Versioning is a safety net, not a substitute for restraint.