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
| Strategy | Where | Pros | Cons |
|---|---|---|---|
| URI | /api/v2/... | Obvious, easy to test, cache-friendly | ”URI should identify a resource, not a version” purism; URL churn |
| Header | X-API-Version: 2 | Clean stable URLs | Invisible in browser; harder to test/cache |
| Content negotiation | Accept: ...v2+json | Most RESTful; per-representation | Verbose; least discoverable; tooling friction |
| Query param | ?version=2 | Trivial | Pollutes query space; weak caching; discouraged |
Recommended approach
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/Sunsetresponse 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.