API versioning strategies and trade-offs
How do you evolve an API without breaking existing clients? A comparison of versioning strategies with their trade-offs, drawn from real-world experience.
Once you publish an API, you’ve essentially told the world “this contract will not change.” Change it, and the clients depending on you break. Don’t change it, and you’re stuck living with your design mistakes forever. API versioning is how you manage this tension — but there’s no single “right” way; you’re choosing between trade-offs.
Why is versioning necessary?
Say you need to change the response structure of a resource. Your user object currently looks like this:
{
"id": 1,
"name": "Muhammet Şafak",
"created_at": "2019-01-15T10:00:00Z"
}
In the new version, name is going to be split into separate first_name and last_name fields. If you make this change in a single version, every client relying on the name field breaks immediately. Versioning lets the old and new structures coexist for a period of time.
Three common strategies
URI versioning
GET /api/v1/users
GET /api/v2/users
The most widely used approach. It’s trivially easy to tell which version a request targets just by looking at the URL — it shows up cleanly in logs, proxy rules, and documentation. The downside: it multiplies your URLs and raises the question “should I maintain separate v1/v2 paths for every resource?”
Header versioning
GET /api/users
Accept: application/vnd.myapi.v2+json
or a custom header:
X-API-Version: 2
The URL stays clean. But what happens if the client sends the header incorrectly or omits it entirely? Does it fall back to a default version? Return an error? That ambiguity becomes part of your contract.
Query parameter
GET /api/users?version=2
A practical starting point, but harder to maintain over time. Cache behavior becomes unpredictable; proxies and CDNs may not account for the query parameter.
What I prefer
On several projects I’ve settled on URI versioning: /api/v1/, /api/v2/. My reasoning is straightforward:
-
Readability. The version you’re calling is visible right in the URL. Debugging a log line is instant — no guesswork.
-
Documentation boundary. Each version can have its own separate documentation. You don’t have to hunt for the version to answer “how does this endpoint behave in v2?”
-
Deployment flexibility. You can route v1 and v2 to separate controllers or route groups. No need to entangle the codebases.
Header-based versioning has theoretical elegance, but in practice, when a client misconfigures a header, tracking down the problem takes considerably longer.
Backward-compatible changes
Not every change requires a new version. Backward-compatible changes can be made within the current version:
- Adding a new field to a response.
- Adding an optional parameter.
- Enriching error messages.
Breaking changes, on the other hand, require a new version:
- Removing or renaming an existing field.
- Adding a required parameter.
- Fundamentally restructuring a response.
- Changing a status code.
When do you retire old versions?
The answer depends on your client base. For an internal API, you can drop v1 quickly and mandate migration to v2. For a publicly exposed API, a deprecation window of at least 6–12 months is reasonable.
You can use HTTP headers to communicate the deprecation timeline:
Deprecation: true
Sunset: Sun, 31 Jan 2021 23:59:59 GMT
The Sunset header is defined by RFC 8594 (May 2019, Informational): it announces that a resource will become unresponsive at a specific point in the future. Browsers and most client libraries don’t act on it by themselves, but it still serves as a meaningful signal to client developers.
The Deprecation: true line above needs a note from today. When I wrote this, the header was only an IETF draft, and the draft gave Deprecation: true as its example. That draft was published as RFC 9745 in March 2025, and the value type changed: Deprecation is now an Item Structured Header field whose value must be a Date. So the correct form today is:
Deprecation: @1780272000
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"; type="text/html"
The value true is no longer valid. The two headers say two different things: Deprecation says when the resource was deprecated, Sunset says when it stops responding.
There’s no perfect versioning strategy. In my own projects I default to URL-based versioning because it’s the most visible approach for client developers and makes it trivial to look at a request and know exactly which version is in play. Header-based versioning may look more elegant on paper, but its invisibility during debugging has burned me more than once. Whichever approach you pick, the important thing is to apply it consistently and communicate clearly with your clients — a versioning strategy only works when it’s predictable.
Sources
Questions on this topic
How should I run the sunset process when deprecating an endpoint in my public API?
Instrument usage per partner, publish a dated sunset with Sunset and Deprecation headers on every response, run brownouts, and cut when usage nears zero.
Experiments on this topic
Swipenor: if the client computes the score, what does the server trust?
A swipe-based true/false trivia game; the real subject is not the game but making a client-computed score verifiable through a signature.
What it does today
The swipe-based true/false quiz runs end to end in its core loop as a mobile app; the score is produced on the client but verified server-side with a server_nonce and an HMAC signature. swipenor.com is the app's landing page, not the game itself.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.