REST API Versioning Strategies
APIs inevitably become hard to change. How can they evolve without breaking existing clients? This article explains URL path, header, query parameter, and content-negotiation versioning; their tradeoffs; when breaking changes are necessary; deprecation strategies; and a practical "forward compatibility first, major versions when necessary" approach.
Key Points
- Topic Focus: TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
An API starts becoming obsolete on the very day it goes live. That is no exaggeration: as long as the business is running, requirements keep changing. Fields need to be added, behavior changed, and legacy implementations refactored. The problem is that you have long since lost track of how many clients call your API: old mobile app versions remain installed on millions of phones, third-party integration code lives in repositories you cannot see, and someone may even have written a script to pull your data on a schedule. Rename one field, and half the internet's applications may start throwing errors that very night.
The core goal of API versioning can be summed up in one sentence: give yourself room to change without suddenly killing old clients. This article explains the available versioning methods, how to choose among them, when a breaking change is truly necessary, how to retire old versions gracefully, and a strategy I have used in practice that teams can actually implement.
1. The Problem Versioning Solves Is Not "Whether You Can Make Changes"
Many people think versioning means "adding /v1/ to the URL." That is the result, not the starting point. The real starting point is the coexistence of two conflicting requirements:
- The server must be able to evolve: add features, fix flawed designs, and address performance bottlenecks.
- Clients cannot be forced to upgrade: mobile app reviews take a week, third-party integrators have no incentive to follow your schedule, and touching a legacy system always risks breaking something.
As long as both conditions hold, versioning is unavoidable. An API without a versioning strategy either becomes too rigid to change or explodes in production whenever it does change. I have seen both outcomes: the former is a slow death, while the latter is an acute incident.
You also need to understand a harsh reality: every line of API code you write is fundamentally a long-term contract. Once it is released, you incur "compatibility debt." Versioning is not about "adding a version number"; it is about managing the repayment schedule for that debt.
2. Four Versioning Methods: Every Choice Has a Cost
There are four mainstream ways to communicate an API version. Choosing among them is essentially a tradeoff between visibility and purity.
1. URL path versioning: /api/v1/users
This is the most intuitive, common, and widely used approach. GitHub, Stripe, and Twitter all used it early on.
GET /api/v1/users/123
GET /api/v2/users/123
- Pros: The version is immediately visible; curl, Postman, and the browser address bar can test it directly; CDNs and reverse proxies naturally cache by URL; and documentation is easy to write.
- Cons: REST creator Roy Fielding himself has criticized this approach. He argues that "a versioned API is not a proper API": a URL should identify a resource, and a version is not a property of that resource. This is theoretically correct, but engineers generally ignore it in practice.
My view: theoretical purity cannot replace debugging efficiency. URL versioning offers such a large visibility advantage that it is the right choice for the vast majority of teams.
2. Header versioning: Accept-Version: 1 or a custom X-API-Version
GET /users/123
Accept-Version: 2
- Pros: Clean URLs, stable resource addresses, and per-request version control.
- Cons: A fatal flaw. The version is invisible in the URL, so the first thing you inspect during troubleshooting is missing critical information. Direct access through a browser address bar can only retrieve the default version. CDNs do not include request headers in cache keys by default, which can mix cached responses across versions. New team members may not even realize that the header exists.
Header versioning is unlikely to hurt you only when the API is internal and every caller is fully controlled.
3. Query parameters: /users?version=1
GET /users/123?version=1
- Pros: Minimal changes; clients only need to add a parameter.
- Cons: The version is mixed with business parameters and is easy to overlook. Old clients omit it and silently default to v1, making everything appear normal until v1 is retired one day. The semantics are also unclear: does
versionrefer to the API version or the data version?
Not recommended. It is merely a compromise that is better than having no versioning at all.
4. Content negotiation: Accept: application/vnd.myapi+json; version=2
This is the most "purely RESTful" approach. It communicates the version through the media type in the Accept header.
GET /users/123
Accept: application/vnd.myapi.v2+json
- Pros: Consistent with HTTP semantics, leaves the URL completely unchanged, and can negotiate the response format (JSON/XML/Proto) and version together.
- Cons: Complexity explodes. Most developers do not understand this header, debugging tools support it poorly, documentation is difficult to write, and CDN caching still requires special configuration.
Here is a concise comparison:
| Method | Visibility | Debugging difficulty | Cache-friendly | RESTful purity | Recommendation |
|---|---|---|---|---|---|
| URL path | Very high | Very easy | Yes | Low | Default choice |
| Header | Low | Hard | Requires configuration | Medium | Suitable for internal APIs |
| Query parameter | Medium | Easy | Yes | Low | Not recommended |
| Content negotiation | Low | Hard | Requires configuration | High | Worth considering for public APIs |
Conclusion: for a public API, choose URL path versioning in 99% of cases. Its debugging and visibility benefits far outweigh the empty prestige of "theoretical purity."
3. When You Truly Need a Breaking Change: Add Whenever You Can, Do Not Delete
A version number is a "breaking-change counter," not something you increment for every modification. Most routine evolution does not require a new version at all. The key is distinguishing between two categories of change.
Forward-compatible changes (no new version required):
- Add an optional field: Return an additional
avatarUrlin the response; old clients can simply ignore it. - Add an optional request parameter:
GET /users?includeDeleted=true; behavior remains unchanged when it is omitted. - Add enum values without removing existing ones: An order status gains
refunded. Even if an old client does not recognize it, displaying "Unknown" is workable. - Add an endpoint: Existing endpoints are unaffected.
Breaking changes (a new version is required):
- Delete or rename a field: Change
nametofullName, and an old client'suser.nameimmediately becomesundefined. - Change a field's type: Change
userIdfrom number to string, and client-side type validation immediately fails. - Add a required field: Old clients do not send the field, so their requests are rejected.
- Change behavioral semantics: Switch pagination from 1-based to 0-based or timestamps from seconds to milliseconds. This is the most insidious kind of breaking change because the type remains the same while all the data becomes wrong.
- Change status-code semantics: A scenario that previously returned 404 now returns 400, so the client's expected error branch is never reached.
One practical technique: leave room for extension from day one.
// Don't do this: flattening fields leaves no room to evolve
type User = { name: string; avatar: string };
// Do this: wrap them in an object so adding fields does not break the structure
type User = {
profile: { name: string; avatar: string };
};
Similarly, always use ISO 8601 strings for time fields instead of numeric Unix timestamps. Strings offer greater precision and expressiveness. Once clients parse a numeric timestamp as seconds, switching to milliseconds later becomes disastrous.
The principle is simple: if forward compatibility can solve the problem, never release a new version. Every major version creates another long-term maintenance obligation.
4. Deprecation Strategy: Releasing v2 Does Not Mean You Can Delete v1
What happens to v1 after v2 is released? Deleting it immediately is the most common form of "suicidal operations." Proper deprecation is a paced process that gives callers time to respond.
1. Mark deprecation with standard headers
HTTP already provides established conventions. The Deprecation header marks the resource as deprecated, while the Sunset header (RFC 8594) provides a specific retirement date.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: </api/v2/users>; rel="successor-version"
This allows client HTTP libraries to detect deprecation signals automatically, emit log warnings, and even notify developers proactively.
2. Mark it clearly in the documentation and contact major customers
Headers alone are insufficient because most teams never inspect response headers. API documentation should mark every deprecated endpoint prominently at the top, with a conspicuous red warning. For known heavy users, such as enterprise customers under contract, proactively send emails or create a group chat and provide a clear migration deadline.
3. Monitor actual traffic to the old version
This is the only valid basis for a retirement decision. Bucket requests by version and send the metrics to Prometheus:
api_requests_total{version="v1"} # This number determines whether you can retire it
api_requests_total{version="v2"}
Do not retire the version until traffic has fallen to a negligible level. What counts as "negligible"? My threshold is v1 traffic below 0.1% of total traffic for 30 consecutive days.
4. Final retirement: return 410 Gone
When the retirement date arrives, return 410 Gone directly and include a link to the migration documentation in the response. Do not pretend that the version still works by returning stale data. If you do, clients will never have an incentive to migrate.
{
"error": {
"code": "VERSION_RETIRED",
"message": "v1 was retired on 2026-12-31. Please migrate to v2",
"docUrl": "https://docs.example.com/migration/v1-to-v2"
}
}
5. Version Coexistence: The Cost of Running Multiple Versions and How to Manage It
Once v2 is released, v1 and v2 must run in parallel for some time. That may mean several months or several years; Stripe has supported four major versions simultaneously. This is the most painful part of versioning.
The simplest solution is two independent implementations. The v1 controller calls the v1 service, and the v2 controller calls the v2 service. The advantage is clean isolation. The disadvantage is diverging business logic: the same "create order" behavior must be maintained in two places, bugs must be fixed twice, and new requirements must be evaluated twice.
A better solution is an adapter layer. Internally, there is only one "source-of-truth implementation," usually the latest version. Older versions use a thin adapter layer to convert requests and responses into the new format.
// v1 handler: just a conversion wrapper
app.get('/api/v1/users/:id', async (req, res) => {
const user = await userService.get(req.params.id); // Always use the latest logic internally
res.json(toV1Shape(user)); // Convert the new structure back to v1's fields
});
// v2 handler: return the latest structure directly
app.get('/api/v2/users/:id', async (req, res) => {
const user = await userService.get(req.params.id);
res.json(toV2Shape(user));
});
This leaves only one copy of the core logic to maintain. v1 merely adds a "shape conversion" layer. The conversion functions themselves must remain stable, but that is far easier than maintaining two sets of business logic.
Going further: gateway-level routing. If your API has high traffic and many versions, you can route by version in an API Gateway (Kong, APISIX, AWS API Gateway), sending different versions to different backend service instances. Old and new services can then be deployed, scaled, and retired independently without affecting one another.
Do not promise indefinite support. Every version should have an expected retirement schedule when it launches; otherwise, v1 will linger like a zombie until the company shuts down.
6. Practice: A Versioning Strategy You Can Implement
The preceding guidance can be condensed into a practical strategy:
- For routine evolution, prioritize forward compatibility. Add fields, optional parameters, and enum values. If one of these solves the problem, do not release a new version.
- Leave room for extension during the design phase. Use nested objects rather than flat structures, strings rather than numbers for time, and enums rather than booleans for state. A boolean has only two values, so adding a third is breaking.
- When a breaking change is unavoidable, run v2 in parallel. Mark v1 as deprecated at the same time and begin a migration window, usually 6-12 months.
- Implement old versions as adapter layers rather than independent systems. Maintain only one copy of the core logic to reduce long-term maintenance costs.
- Let monitoring drive retirement. Look at actual traffic, not just the calendar. As long as 0.1% of real calls remain, do not actually delete the version.
- Use standard retirement signals:
Deprecation+Sunsetheaders + documentation + 410 Gone.
The central idea behind this strategy is: a major version is a last resort, not an everyday tool. Every v2 release is an admission that the previous design could not withstand the required change, so the less frequently it happens, the better.
7. Lessons from GraphQL and Semantic Versioning
Finally, consider two related ideas that offer direct lessons for REST versioning.
GraphQL's evolution philosophy. GraphQL almost never needs a "major version." The reason is that clients request only the fields they need. Add a field, and old queries never request it, making the change naturally forward-compatible. To remove a field, mark it with the @deprecated directive. Tooling can detect which queries still use it, and you can delete it once nobody does. The entire schema evolves continuously; there is no "v1/v2 switch."
The lesson for REST is: the more closely an interface matches the shape clients actually use, the more smoothly it can evolve. REST over-fetching is not just a performance problem; it is also a versioning problem. The more fields a response contains, the larger the surface area for breaking changes. Field selection, such as supporting ?fields=id,name or GraphQL-style queries, can significantly reduce the frequency of breaking changes.
The limits of Semantic Versioning (SemVer). SemVer (MAJOR.MINOR.PATCH) was designed for packages and libraries, but APIs can borrow its semantics: MAJOR represents breaking changes, MINOR represents backward-compatible new functionality, and PATCH represents bug fixes. However, API URL versions generally do not advance according to SemVer; they use major identifiers such as v1/v2. The reason is simple: every API breaking change is a "major event" that warrants a complete migration cycle, while SemVer MAJOR versions would advance too frequently for clients to keep up.
The correct approach is: use SemVer internally to manage SDK and client-library versions, and use major version numbers externally to control the pace of API breaking changes. Each serves its own purpose; do not conflate them.
Summary
- Versioning is fundamentally about managing compatibility debt: the goal is to let the API change without killing old clients, not merely to "add a version number."
- Default to URL path versioning among the four methods:
/v1/usersoffers the best combination of visibility, debugging, and caching. Headers and content negotiation are worthwhile only in specific scenarios. - Prioritize forward compatibility: add fields rather than deleting or changing them, and leave room for extension during design through nested structures, string-based time values, and enums instead of booleans. Routine evolution should not require a new version.
- Breaking changes require the complete process: release v2 in parallel → mark v1 as deprecated (
Deprecation/Sunsetheaders + documentation) → monitor old-version traffic → retire it with410 Goneonce traffic reaches the threshold. Use an adapter layer rather than an independent implementation for old versions to reduce maintenance costs. - Learn from GraphQL and SemVer: interfaces evolve more smoothly when they match the shape clients actually use. Use SemVer internally for SDKs and major version numbers externally to manage the pace of breaking changes.
The takeaway: API versioning is not a technology-selection problem; it is a contract-management problem. How quickly you can change depends on how long you dare keep old clients alive. Forward compatibility is routine, a major version is an incident, and traffic monitoring is the only valid basis for retirement.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.