Skip to content
Spring Boot sb web 3 min read

API Versioning

APIs evolve, but existing clients must keep working. Versioning lets you ship breaking changes behind a new version while old clients stay on the previous one. Spring MVC supports several strategies through ordinary request-mapping conditions. This page shows URI, header, and content-negotiation versioning, with their trade-offs and a recommended default.

When to version

Version only on breaking changes — removing a field, renaming it, changing a type, or altering semantics. Additive changes (new optional fields, new endpoints) are backward-compatible and need no new version. Premature versioning multiplies maintenance for no benefit.

URI versioning

The version lives in the path. This is the most visible and cache-friendly approach, and the easiest to test from a browser or curl.

@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
    @GetMapping("/{id}")
    public UserV1 one(@PathVariable Long id) { ... }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
    @GetMapping("/{id}")
    public UserV2 one(@PathVariable Long id) { ... }
}

Request:

curl http://localhost:8080/api/v2/users/42

Output:

{ "id": 42, "fullName": "Ada Lovelace", "createdAt": "2026-01-10T08:00:00Z" }

Header versioning

The version is carried in a custom request header, matched with the headers condition. The URL stays clean and stable across versions.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserV1 v1(@PathVariable Long id) { ... }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserV2 v2(@PathVariable Long id) { ... }
}

Request:

curl http://localhost:8080/api/users/42 -H "X-API-Version: 2"

Content-negotiation versioning

The version is embedded in a custom media type via the Accept header, matched with produces. This is the most RESTful approach but the least discoverable.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", produces = "application/vnd.example.v1+json")
    public UserV1 v1(@PathVariable Long id) { ... }

    @GetMapping(value = "/{id}", produces = "application/vnd.example.v2+json")
    public UserV2 v2(@PathVariable Long id) { ... }
}

Request:

curl http://localhost:8080/api/users/42 \
  -H "Accept: application/vnd.example.v2+json"

Comparing the strategies

StrategyWhereProsCons
URI/api/v2/...Obvious, easy to test, cache-friendly”URI should identify a resource, not a version” purism; URL churn
HeaderX-API-Version: 2Clean stable URLsInvisible in browser; harder to test/cache
Content negotiationAccept: ...v2+jsonMost RESTful; per-representationVerbose; least discoverable; tooling friction
Query param?version=2TrivialPollutes query space; weak caching; discouraged

For most public and internal APIs, URI versioning is the pragmatic default: it is unambiguous, trivially testable, works with every client and proxy, and is the most common convention developers expect. Reserve header or media-type versioning for APIs with strict REST/HATEOAS requirements or where stable URLs are a hard constraint.

Whichever you choose, apply these practices:

  • Version the whole API (/v1, /v2), not individual endpoints, to keep mental overhead low.
  • Keep version-specific DTOs (e.g. UserV1, UserV2) and map from a shared internal model so controllers stay thin.
  • Publish a deprecation policy and signal sunsetting with the standard Deprecation / Sunset response headers.
@GetMapping("/api/v1/users/{id}")
public ResponseEntity<UserV1> deprecatedV1(@PathVariable Long id) {
    return ResponseEntity.ok()
            .header("Deprecation", "true")
            .header("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT")
            .body(service.findV1(id));
}

Tip: Avoid maintaining more than two live versions at once. Each extra version multiplies testing, documentation, and bug-fix surface.

Warning: Do not change an existing version’s contract “just a little.” Even a renamed field breaks clients. If it breaks, it is a new version.

Pitfalls

  • Mixing strategies across endpoints confuses clients — standardize on one.
  • Forgetting to version the matching DTOs means a “v2” endpoint silently returns the v1 shape.
  • Header and media-type versions are easy to misspell; document the exact strings in your OpenAPI spec.
Last updated June 13, 2026
Was this helpful?