API Versioning Strategies
Comparing URI, header, query and media-type versioning honestly, deciding what counts as breaking, running two versions at once, and retiring one without breaking clients.
On this page
Versioning is not really about URLs. It is about deciding what you have promised, noticing when you are about to break that promise, and giving consumers a path forward. The URL scheme is the easy part.
Key Takeaways
- URI versioning wins on practicality for public APIs; media-type versioning wins on elegance.
- Most changes do not need a new version. Learn which ones do.
- Run old and new versions over one implementation with a translation layer, not two codebases.
- Announce retirement with
DeprecationandSunsetheaders and a dated migration guide. - Measure usage per version — you cannot retire what you cannot see.
The four options
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI path | /api/v2/orders | Visible, cacheable, browsable, trivial to route | Same resource has two URLs |
| Custom header | X-API-Version: 2 | Clean URLs | Invisible in logs, hard to test in a browser, breaks naive caches |
| Query parameter | /orders?version=2 | Easy default | Clutters the query string, cache-key issues |
| Media type | Accept: application/vnd.acme.order.v2+json | Correct content negotiation, per-resource versioning | Unfamiliar, verbose, easy for clients to get wrong |
There is a fifth option that is often the right one: do not version the API at all, and evolve it additively forever. This is what most large platforms actually do. It requires discipline about what you add and a willingness to keep a deprecated field returning a sensible value indefinitely, but it spares every consumer a migration.
If you do version, a note on caching: with header or media-type versioning you must send
Vary: Accept or Vary: X-API-Version, or a shared cache will serve a v1 response to a v2 client.
This is the failure mode people discover in production, and it is why URI versioning stays popular
despite being less pure.
What actually breaks a client
Two of these surprise people. Adding an enum value breaks any client that switches exhaustively over the enum, which generated clients typically do — so new statuses should be introduced only with a documented "unknown values may appear" contract, ideally stated in v1. And tightening validation breaks callers who were relying on the looser rule, even though the change makes your data cleaner. Both are breaking; both look additive.
Running two versions
The mistake is forking the codebase. Two implementations diverge, bug fixes land in one, and the maintenance cost doubles permanently. Keep one implementation and translate at the edge:
@RestController
@RequestMapping("/api/v1/orders")
@Deprecated(since = "2026-07-01")
public class OrderControllerV1 {
private final OrderService service; // the single implementation
private final OrderV1Translator translator; // v2 shape -> v1 shape
@GetMapping("/{id}")
public OrderV1Response get(@PathVariable String id) {
return translator.toV1(service.find(id));
}
}
@Component
public class OrderV1Translator {
public OrderV1Response toV1(OrderResponse current) {
return new OrderV1Response(
current.id(),
current.customerId(),
// v1 exposed a flat total in major units; v2 uses minor units
// plus an explicit currency. The translation lives here, not in
// the service, so v1 can be deleted in one commit.
current.total().amount().movePointLeft(2),
current.status().name());
}
}When the sunset date arrives, deleting the v1 controller and its translator removes the entire version. Nothing in the service layer knows v1 existed.
Announcing deprecation
@Component
public class DeprecationHeaders implements HandlerInterceptor {
private static final String SUNSET = "Wed, 01 Jul 2026 00:00:00 GMT";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
if (request.getRequestURI().startsWith("/api/v1/")) {
// RFC 8594 Sunset, plus the Deprecation header from the
// deprecation-header draft. Both are machine-readable.
response.setHeader("Deprecation", "true");
response.setHeader("Sunset", SUNSET);
response.addHeader("Link",
"<https://docs.acme.com/migrate/v1-to-v2>; rel=\"deprecation\"; type=\"text/html\"");
response.addHeader("Warning",
"299 - \"API v1 is deprecated and will be removed on 2026-07-01\"");
}
return true;
}
}Headers alone are not a plan. A retirement that goes smoothly has four parts: an announcement with a
dated deadline, a migration guide showing before-and-after for every change, per-consumer usage data
so you can contact the stragglers directly, and — for a large API — a brake test, where you return
503 for the old version for a few minutes on an announced date so remaining consumers discover
their dependency before the permanent removal.
Measuring usage
@Component
public class VersionMetrics implements HandlerInterceptor {
private final MeterRegistry registry;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object h) {
String version = request.getRequestURI().startsWith("/api/v1/") ? "v1" : "v2";
// Client id is bounded (it comes from a registered credential), so it is
// safe as a tag. A user id would not be.
registry.counter("api.requests",
"version", version,
"client", clientIdOf(request)).increment();
return true;
}
}A dashboard of requests per version per client is what turns retirement from a guess into a decision. It also answers the question you will be asked by whoever has to sign off the removal: exactly who is still calling this, and how often.
Documenting the evolution
Keep a changelog with dates, and classify each entry as added, changed, deprecated or removed. It costs a line per change and it is the first thing an integrating developer looks for when something behaves differently from the last time they read the docs.
Pair it with the CI spec diff from the OpenAPI workflow: the diff catches the breaking change, the changelog explains it, and the deprecation headers tell running clients. Those three together are the whole versioning strategy — the URL scheme is a detail by comparison.
What to take away
Prefer URI versioning unless you have a specific reason not to, and remember Vary if you choose
headers. Run every change through the breaking-change checklist before creating a version at all.
Keep one implementation with translators at the edge, announce retirement with dates and headers, and
measure per-version usage so you know when it is safe to delete.
Frequently Asked Questions
Which versioning strategy should I pick?
Is adding a field a breaking change?
How long should an old version stay alive?
Related tutorials
- API Documentation with OpenAPIGenerating documentation people actually use: springdoc-openapi setup, annotations worth adding, grouping large APIs, documenting errors and auth, and the API-first workflow.
- Pagination, Filtering & SortingPagination that stays fast at depth: why OFFSET degrades, keyset and cursor pagination, dynamic filtering with Specifications, safe sorting, and the RFC 8288 Link header.
- Spring REST Controllers Deep DiveEverything a Spring controller can bind, how ResponseEntity builds responses properly, content negotiation, custom argument resolvers, and keeping controllers thin.
- Rate Limiting & ThrottlingThe five rate-limiting algorithms compared, distributed limiting with Redis and Bucket4j, per-tier quotas, and the response headers clients need to behave well.