Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate5 min readUpdated
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 Deprecation and Sunset headers and a dated migration guide.
  • Measure usage per version — you cannot retire what you cannot see.

The four options

StrategyExampleProsCons
URI path/api/v2/ordersVisible, cacheable, browsable, trivial to routeSame resource has two URLs
Custom headerX-API-Version: 2Clean URLsInvisible in logs, hard to test in a browser, breaks naive caches
Query parameter/orders?version=2Easy defaultClutters the query string, cache-key issues
Media typeAccept: application/vnd.acme.order.v2+jsonCorrect content negotiation, per-resource versioningUnfamiliar, 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

Run every change through this before reaching for a new version. Most changes come out the bottom.

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:

OrderControllerV1.java
@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

DeprecationHeadersFilter.java
@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

VersionMetrics.java
@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?
URI versioning for a public API. It is visible in logs, works in a browser, caches correctly and needs no client configuration. Media-type versioning is more elegant and better for per-resource evolution, but it is harder to test, harder to debug and unfamiliar to most consumers. Pick the one your consumers will get right.
Is adding a field a breaking change?
Adding an optional response field is safe if clients tolerate unknown properties — most JSON parsers do, but a strictly generated client with additionalProperties false will reject it. Adding a required request field is always breaking. Narrowing an enum, tightening validation and changing a field type are all breaking even though they look additive.
How long should an old version stay alive?
Long enough for your slowest consumer to migrate, which for a public API means six to twelve months after the deprecation announcement. Publish the sunset date up front, send Deprecation and Sunset headers on every response, and track usage per version so you know who is left.

Related tutorials