API Versioning Strategies (and How to Avoid Breaking Clients)
A practical look at REST and GraphQL versioning, breaking changes, deprecation policy, and the pragmatic default we actually reach for.
Key takeaways
A practical look at REST and GraphQL versioning, breaking changes, deprecation policy, and the pragmatic default we actually reach for.
On this page
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:
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:
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
GraphQL vs REST: When to Use Each in 2026
A practical guide to choosing between GraphQL and REST based on how your clients fetch data, cache, and evolve over time.
API Pagination: Offset vs Cursor (and When to Use Each)
Offset pagination is easy until your dataset grows or shifts under load; here's why cursor pagination wins for large, changing, and public APIs.
More from DevOps
Explore more articles in this category
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.