Spring Boot REST API Versioning
APIs change: new features arrive, fields are renamed, responses are restructured. But once mobile apps, partner systems or other services depend on your API, every change risks breaking them. API versioning lets you introduce changes in a new version while existing clients keep using the old one, safely and on their own schedule.
This tutorial explains when versioning is needed, walks through the four classic strategies with one realistic example, introduces the built-in versioning support of Spring Framework 7 and Spring Boot 4, and covers deprecation, documentation and testing.
1. What Is API Versioning?
An API version is a label, such as v1 or 2, that tells the server which contract a client expects: which fields a request contains and what the response looks like. The server can then offer several contracts at once:
Client A (old mobile app) ──▶ GET /api/v1/customers/7 ──▶ { "name": "Asha Rao" }
Client B (new web app) ──▶ GET /api/v2/customers/7 ──▶ { "firstName": "Asha", "lastName": "Rao" }
Both clients work at the same time, against the same server and the same database.
2. Why Do We Need API Versioning?
You cannot update every client at the moment you deploy. Mobile users update their apps weeks later, and partner companies follow their own release plans. Versioning is needed only for breaking changes:
| Change | Breaking? | Needs a new version? |
|---|---|---|
| Adding a new optional field to a response | No | No |
| Adding a new endpoint | No | No |
| Adding an optional request field | No | No |
| Removing or renaming a response field | Yes | Yes |
| Changing a field’s type, such as a number to text | Yes | Yes |
| Making an optional request field required | Yes | Yes |
| Changing the meaning of a value or status code | Yes | Yes |
Clients should ignore fields they do not know, so adding is safe. Removing, renaming or changing the meaning of something is not.
3. A Breaking Change, Step by Step
The examples on this page use one realistic change. Version 1 returns a customer’s full name in a single field. Version 2 splits it into first and last name, which is a breaking change, because old clients read name:
public record CustomerV1(Long id, String name, String email) {
public static CustomerV1 from(Customer customer) {
return new CustomerV1(customer.getId(),
customer.getFirstName() + " " + customer.getLastName(),
customer.getEmail());
}
}
public record CustomerV2(Long id, String firstName, String lastName, String email) {
public static CustomerV2 from(Customer customer) {
return new CustomerV2(customer.getId(), customer.getFirstName(),
customer.getLastName(), customer.getEmail());
}
}
The entity and the service stay the same for both versions. Only the response format differs, which is exactly what versioning should isolate.
4. Common Versioning Strategies
| Strategy | Where the version goes | Example |
|---|---|---|
| URI path | In the URL path | GET /api/v2/customers/7 |
| Request parameter | In the query string | GET /api/customers/7?version=2 |
| Header | In a custom request header | X-API-Version: 2 |
| Media type | In the Accept header | Accept: application/vnd.shop.customer-v2+json |
5. URI Path Versioning
The version is part of the URL. This is the most common and most visible approach. A separate controller per version keeps each contract clear:
@RestController
@RequestMapping("/api/v1/customers")
public class CustomerControllerV1 {
private final CustomerService customerService;
public CustomerControllerV1(CustomerService customerService) {
this.customerService = customerService;
}
@GetMapping("/{id}")
public CustomerV1 get(@PathVariable Long id) {
return CustomerV1.from(customerService.findById(id));
}
}
@RestController
@RequestMapping("/api/v2/customers")
public class CustomerControllerV2 {
private final CustomerService customerService;
public CustomerControllerV2(CustomerService customerService) {
this.customerService = customerService;
}
@GetMapping("/{id}")
public CustomerV2 get(@PathVariable Long id) {
return CustomerV2.from(customerService.findById(id));
}
}
| Pros | Cons |
|---|---|
| Easy to see, use and test, even in a browser | The URL changes for each version |
| Simple routing in gateways and load balancers | Some argue a resource should keep one URL |
| Easy to document separately per version | Clients must update URLs to upgrade |
6. Request Parameter Versioning
The version is a query parameter, and Spring chooses the method with the params attribute:
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
private final CustomerService customerService;
public CustomerController(CustomerService customerService) {
this.customerService = customerService;
}
@GetMapping(value = "/{id}", params = "version=1")
public CustomerV1 getV1(@PathVariable Long id) {
return CustomerV1.from(customerService.findById(id));
}
@GetMapping(value = "/{id}", params = "version=2")
public CustomerV2 getV2(@PathVariable Long id) {
return CustomerV2.from(customerService.findById(id));
}
}
| Pros | Cons |
|---|---|
| The resource URL stays the same | Mixes versioning with query parameters meant for filtering |
| Easy to try in a browser | Easy to forget; a request without version matches nothing and fails |
7. Header-Based Versioning
The client sends the version in a custom header, and Spring matches it with the headers attribute:
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public CustomerV1 getV1(@PathVariable Long id) {
return CustomerV1.from(customerService.findById(id));
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public CustomerV2 getV2(@PathVariable Long id) {
return CustomerV2.from(customerService.findById(id));
}
curl -H "X-API-Version: 2" http://localhost:8080/api/customers/7
| Pros | Cons |
|---|---|
| Clean, stable URLs | Not visible in the URL, so harder to spot in logs and links |
| Version is separate from the resource | Cannot be tested by typing a URL in a browser |
| Works well with API gateways | Caches must vary by the header (see the FAQ) |
8. Media Type Versioning
Also called content negotiation, this approach puts the version in a custom media type in the Accept header. Spring matches it with produces:
@GetMapping(value = "/{id}", produces = "application/vnd.shop.customer-v1+json")
public CustomerV1 getV1(@PathVariable Long id) {
return CustomerV1.from(customerService.findById(id));
}
@GetMapping(value = "/{id}", produces = "application/vnd.shop.customer-v2+json")
public CustomerV2 getV2(@PathVariable Long id) {
return CustomerV2.from(customerService.findById(id));
}
curl -H "Accept: application/vnd.shop.customer-v2+json" http://localhost:8080/api/customers/7
The vnd. prefix marks a vendor-specific media type, and +json tells tools the body is still JSON.
| Pros | Cons |
|---|---|
| Versions the representation, not the resource: closest to REST principles | The most complex for clients to use |
| Different resources can evolve independently | Hard to test manually and to document |
9. Comparing the Strategies
| URI path | Request parameter | Header | Media type | |
|---|---|---|---|---|
| Visible in the URL | Yes | Yes | No | No |
| Easy to test in a browser | Yes | Yes | No | No |
| Stable resource URL | No | Yes | Yes | Yes |
| Simple HTTP caching | Yes | Yes | Needs Vary | Needs Vary |
| Ease for clients | Very easy | Easy | Medium | Harder |
| Popularity | Most common | Less common | Common | Less common |
10. Built-in API Versioning in Spring Framework 7
Spring Framework 7, used by Spring Boot 4, adds first-class API versioning. Instead of separate URLs or headers conditions on every method, you choose the strategy once and declare a version on each mapping.
10.1 Choose where the version comes from
In Spring Boot 4, the simplest way is a property:
spring.mvc.apiversion.use.header=X-API-Version
spring.mvc.apiversion.default=1.0
Or configure it in code, where more options are available:
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("X-API-Version")
.setDefaultVersion("1.0")
.addSupportedVersions("1.0", "2.0");
}
}
ApiVersionConfigurer method | Reads the version from |
|---|---|
useRequestHeader(String) | A request header |
useQueryParam(String) | A query parameter |
useMediaTypeParameter(MediaType, String) | A parameter of the media type, such as application/json;version=2.0 |
usePathSegment(int) | A segment of the URL path, by position |
10.2 Declare versions on mappings
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
private final CustomerService customerService;
public CustomerController(CustomerService customerService) {
this.customerService = customerService;
}
@GetMapping(value = "/{id}", version = "1.0")
public CustomerV1 getV1(@PathVariable Long id) {
return CustomerV1.from(customerService.findById(id));
}
@GetMapping(value = "/{id}", version = "2.0+")
public CustomerV2 getV2(@PathVariable Long id) {
return CustomerV2.from(customerService.findById(id));
}
}
The + in "2.0+" makes it a baseline: this method handles version 2.0 and every supported version above it, until a newer method takes over. When you later add version 2.1 for a different endpoint, this one keeps working for 2.1 without changes. If several methods qualify, Spring chooses the highest version that is not above the requested one.
| Behavior | Result |
|---|---|
| Version missing, and no default set | 400 Bad Request (a version is required by default) |
| Version missing, default set | The default version is used |
| Version not supported | 400 Bad Request |
Methods without a version | Match any version, but with the lowest priority |
Versions are compared by number, as major.minor.patch, so 2.10 is correctly newer than 2.9. The same support exists in Spring WebFlux, and on the client side in RestClient and WebClient. On Spring Boot 3, use one of the four manual strategies above.
11. Sharing Logic Between Versions
A new version should not mean a copy of the whole application. Keep one service and one data model, and make versions differ only at the edges, in the controllers and DTOs:
CustomerControllerV1 -> CustomerV1 DTO ─┐
CustomerControllerV2 -> CustomerV2 DTO ─┤
▼
CustomerService (shared)
▼
CustomerRepository (shared)
Requests follow the same rule: map each version’s request DTO to one shared command before calling the service.
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CustomerV1 create(@Valid @RequestBody CreateCustomerV1 request) {
// V1 clients send one "name" field: split it for the shared service
String[] parts = request.name().trim().split("\\s+", 2);
NewCustomer command = new NewCustomer(parts[0], parts.length > 1 ? parts[1] : "", request.email());
return CustomerV1.from(customerService.create(command));
}
Business rules then live in one place, and a bug fix applies to every version at once.
12. Deprecating and Retiring Versions
Every version you keep costs maintenance, so plan its end from the start:
- Announce the deprecation in your documentation and changelog.
- Signal it in responses with standard headers, so clients notice automatically.
- Measure usage of the old version, and contact the remaining clients.
- Retire it after the announced sunset date.
With Spring Framework 7, the built-in deprecation handler adds the standard Deprecation, Sunset and Link headers for chosen versions:
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
StandardApiVersionDeprecationHandler deprecations = new StandardApiVersionDeprecationHandler();
deprecations.configureVersion("1.0")
.setDeprecationDate(ZonedDateTime.parse("2026-10-01T00:00:00Z"))
.setSunsetDate(ZonedDateTime.parse("2027-04-01T00:00:00Z"))
.setDeprecationLink(URI.create("https://api.example.com/docs/migrate-to-v2"));
configurer.useRequestHeader("X-API-Version")
.addSupportedVersions("1.0", "2.0")
.setDeprecationHandler(deprecations);
}
On older versions, set the headers yourself in the old controller:
@GetMapping("/{id}")
public ResponseEntity<CustomerV1> get(@PathVariable Long id) {
return ResponseEntity.ok()
.header("Deprecation", "@1790812800") // 2026-10-01, as a Unix timestamp
.header("Sunset", "Thu, 01 Apr 2027 00:00:00 GMT")
.header("Link", "<https://api.example.com/docs/migrate-to-v2>; rel=\"deprecation\"")
.body(CustomerV1.from(customerService.findById(id)));
}
Never remove a version without warning. Give clients a clear timeline, typically six to twelve months for public APIs.
13. Documenting Versions with OpenAPI
Clients need separate documentation for each version. With springdoc-openapi and URI versioning, define one group per version:
@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("v1")
.pathsToMatch("/api/v1/**")
.build();
}
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("v2")
.pathsToMatch("/api/v2/**")
.build();
}
}
Swagger UI then offers a drop-down to switch between the two versions. Mark deprecated endpoints with Java’s @Deprecated annotation, and they appear crossed out in the documentation.
14. Testing Versioned APIs
Test every supported version, so a change for v2 never breaks v1 by accident:
@WebMvcTest(CustomerController.class)
class CustomerVersioningTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private CustomerService customerService;
@BeforeEach
void setUp() {
when(customerService.findById(7L))
.thenReturn(new Customer(7L, "Asha", "Rao", "asha@example.com"));
}
@Test
void version1ReturnsFullName() throws Exception {
mockMvc.perform(get("/api/customers/7").header("X-API-Version", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Asha Rao"))
.andExpect(jsonPath("$.firstName").doesNotExist());
}
@Test
void version2ReturnsSplitName() throws Exception {
mockMvc.perform(get("/api/customers/7").header("X-API-Version", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Asha"))
.andExpect(jsonPath("$.lastName").value("Rao"));
}
}
Checking that v1 does not contain the new fields is as important as checking the fields it does contain: it proves the old contract is unchanged.
15. Best Practices
- Version from day one, for example
/api/v1/..., even if you never need v2. - Create a new version only for breaking changes; add new fields and endpoints to the current one.
- Use whole numbers for public versions: v1, v2, v3.
- Keep one business layer; let versions differ only in controllers and DTOs.
- Support as few versions as possible, usually the current one and the previous one.
- Announce deprecation early with dates and headers, and measure who still uses old versions.
- Document every version, and test every supported version.
- Pick one strategy and use it consistently across the whole API.
16. Which Strategy Should You Choose?
| Strategy | Best for |
|---|---|
| URI path | Public APIs, beginners and most teams; the safest default |
| Request parameter | Simple or internal APIs where URLs must stay fixed |
| Header | APIs behind gateways with controlled, well-documented clients |
| Media type | Large APIs where resources evolve independently |
| Spring Framework 7 built-in | New Spring Boot 4 projects: any of the above, with less code |
For most applications, URI path versioning offers the best balance of simplicity and clarity. On Spring Boot 4, the built-in support makes header-based versioning almost as simple, if you prefer stable URLs.
17. Summary
API versioning lets you change an API without breaking the clients that depend on it. Version only for breaking changes, choose one strategy, URI path, request parameter, header or media type, and apply it consistently. Keep one shared business layer with version-specific controllers and DTOs, deprecate old versions with clear dates and headers, document and test every version, and on Spring Boot 4, let Spring’s built-in versioning do the routing for you. For the basics of building the API itself, see the Spring Boot REST API tutorial.
18. Frequently Asked Questions (FAQ)
Q1. Should I add a version to my API from the very first release?
Yes. Starting with /api/v1/..., or a version header, costs nothing today and saves a painful change later. If your first release has no version, introducing v2 means either treating the unversioned URLs as an implicit v1, which is confusing, or breaking every existing client.
Many APIs never need a v2, and that is fine: the v1 simply stays.
Q2. Should the URL contain v2 or v2.1?
For public URLs, use whole numbers only: v1, v2, v3. A new number should mean “this contract is incompatible with the previous one”, which happens rarely.
Compatible improvements, such as new fields, do not need a new public version at all. If you want finer tracking, keep it internally, for example in your changelog or with Spring Framework 7’s semantic versions in a header, but keep the public URL simple.
Q3. How many API versions should I support at the same time?
As few as possible, typically two: the current version and the previous one. Every extra version means more code to maintain, test and secure, and every bug fix may need to be checked in each version.
A clear policy helps everyone, for example: “a version is supported for 12 months after its successor is released”. Publish it, so clients can plan their upgrades.
Q4. Do internal microservice APIs need versioning too?
Usually yes, because services are deployed independently. During a rolling deployment, an old version of service A can call a new version of service B, or the other way round.
Internally, it is often enough to design every change to be backward compatible: add fields instead of renaming them, and remove old fields only once no caller uses them. Formal version numbers become necessary when a breaking change cannot be avoided, or when many teams depend on the service.
Q5. Should I version the whole API or each endpoint separately?
Versioning the whole API, so that /api/v2/... applies to every endpoint, is simpler for clients: they pick one version and use it everywhere. The downside is that v2 may contain many endpoints that are identical to v1.
Per-resource versioning, for example with media types, lets each resource evolve on its own, but clients must track a version for every resource. Spring Framework 7’s baseline versions, such as "2.0+", offer a middle way: one API version number, without duplicating endpoints that did not change.
Q6. How does API versioning work with an API gateway?
A gateway can route requests by version, which is useful when a new version is served by a different deployment:
spring:
cloud:
gateway:
routes:
- id: customers-v1
uri: http://customer-service-v1:8080
predicates:
- Path=/api/v1/customers/**
- id: customers-v2
uri: http://customer-service-v2:8080
predicates:
- Path=/api/v2/customers/**
Header-based versions can be routed with a Header predicate in the same way. The gateway is also a good place to measure traffic per version and to add deprecation headers centrally. See the API Gateway Pattern.
Q7. How do several API versions work with one database?
All versions should share the same database and schema. The differences belong in the mapping layer: each version’s DTO is built from, or converted to, the same entities.
When the schema itself must change, make the change compatible with every supported version, using the expand and contract approach: add new columns first, keep old ones while old versions need them, and remove them only after those versions are retired. The Database Migration tutorial shows how.
Q8. Do request bodies need versioning too, or only responses?
Both. A breaking change in what clients send is just as harmful as one in what they receive. If v2 requires firstName and lastName, v1 clients still send name, so each version needs its own request DTO:
public record CreateCustomerV1(@NotBlank String name, @Email String email) { }
public record CreateCustomerV2(@NotBlank String firstName,
@NotBlank String lastName,
@Email String email) { }
Both are converted to the same internal command before reaching the service, as shown in section 11.
Q9. How does header-based versioning affect HTTP caching?
Caches, such as CDNs and browser caches, normally store responses by URL. With header versioning, v1 and v2 share the same URL, so a cache could return a v1 response to a v2 client. Tell caches that the response depends on the header:
return ResponseEntity.ok()
.header(HttpHeaders.VARY, "X-API-Version")
.body(CustomerV2.from(customer));
The same applies to media type versioning, with Vary: Accept. URI versioning avoids this issue entirely, which is one reason it is so popular.
Q10. What should the API return for an unsupported version?
A clear client error with a helpful message:
- URI versioning: an unknown path like
/api/v9/...naturally returns 404 Not Found. - Header or parameter versioning: return 400 Bad Request, explaining which versions are supported. Spring Framework 7’s built-in support does this automatically.
{
"title": "Unsupported API version",
"status": 400,
"detail": "API version 9 is not supported. Supported versions: 1, 2."
}
Q11. Should the version be optional, with a default?
It is convenient, but think about which default you choose:
- Default to the oldest supported version: existing clients that never sent a version keep working. This is the safer choice for public APIs.
- Default to the latest version: convenient for new clients, but every new release silently changes what unversioned clients receive, which may break them.
Many teams make the version required for new integrations, and keep a default only for clients from before versioning was introduced.
Q12. Does semantic versioning make sense for APIs?
Partly. In semantic versioning (major.minor.patch), only the major number signals incompatible changes, and that is exactly what an API version needs to express to clients.
Minor and patch numbers are useful internally, for release notes and for Spring Framework 7’s baseline matching, but clients rarely need to request them. A practical rule: clients choose the major version; everything compatible within it reaches them automatically.
Q13. Why do GraphQL APIs usually not use versions?
In GraphQL, each client asks for exactly the fields it needs. Adding fields never affects existing clients, and old fields can be marked @deprecated in the schema, which tools display as a warning, until nobody requests them anymore.
REST APIs can borrow this idea: prefer adding over changing, mark old fields as deprecated in your documentation, and track whether clients still use them. That way, a new version becomes necessary much less often.
Q14. How can I find out which clients still use an old version?
Measure it. Record the version with every request, together with a client identifier such as an API key or application name:
@Component
public class ApiVersionMetricsFilter extends OncePerRequestFilter {
private final MeterRegistry registry;
public ApiVersionMetricsFilter(MeterRegistry registry) {
this.registry = registry;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String version = Optional.ofNullable(request.getHeader("X-API-Version")).orElse("none");
String client = Optional.ofNullable(request.getHeader("X-Client-Id")).orElse("unknown");
registry.counter("api.requests", "version", version, "client", client).increment();
chain.doFilter(request, response);
}
}
A dashboard of requests per version and client shows exactly whom to contact before retiring a version. Keep the number of distinct client values limited, so the metric stays small.
Q15. Should I put version numbers in my Java package names?
It helps to group the version-specific classes, and only those:
com.javabytechie.shop.customer
├── Customer.java <- shared entity
├── CustomerService.java <- shared business logic
├── api/v1/
│ ├── CustomerControllerV1.java
│ └── CustomerV1.java
└── api/v2/
├── CustomerControllerV2.java
└── CustomerV2.java
Never copy services, repositories or entities into versioned packages. If you find yourself doing that, the versions are drifting apart in their business rules, which is a much bigger problem than the API format.
Q16. Can I use Spring Framework 7’s built-in versioning with WebFlux?
Yes. The same concept, with the version attribute on mappings and a configurer in the WebFlux configuration, is available for reactive applications. On the client side, WebClient and RestClient can also add the version to outgoing requests automatically, so calling a versioned API needs no hand-written headers.
Q17. How should I version messages and events, for example in Kafka?
Events have the same problem as APIs, often worse, because old messages stay in the topic and may be read again later. Common approaches:
- Compatible evolution: only add optional fields, so old and new consumers can read every message.
- A schema registry with formats such as Avro or Protobuf, which checks compatibility before a new schema is accepted.
- A new event type or topic, such as
CustomerCreatedV2, when a breaking change is unavoidable.
Q18. Do I need versioning if I control every client?
Possibly not. If your API serves only your own web front end, and both are always deployed together as one release, there is never an old client calling the new API, so a breaking change can be made in one step.
As soon as any of these applies, you need versioning or strictly compatible changes:
- Mobile apps, which users update at different times.
- Other teams’ services or external partners.
- Front end and back end deployed separately, or browsers keeping old pages open during a deployment.
Q19. How should I tell clients about a new version?
Use several channels, because different people watch different places:
- A changelog listing every change, with migration steps for breaking ones.
- Updated API documentation for each version.
DeprecationandSunsetheaders on the old version, which automated tools can detect.- Direct messages to the clients your metrics show are still on the old version.
A short migration guide, “what changed and exactly how to update”, gets clients to upgrade faster than any deadline.
Q20. Is it a good idea to support versions with if statements inside one method?
Only for very small differences, and usually not even then. Code like this grows quickly and becomes hard to test:
// Avoid: every new version adds another branch
@GetMapping("/{id}")
public Object get(@PathVariable Long id, @RequestHeader("X-API-Version") int version) {
Customer customer = customerService.findById(id);
if (version == 1) {
return CustomerV1.from(customer);
}
return CustomerV2.from(customer);
}
Separate methods or controllers per version, as shown in this tutorial, keep each contract readable, give each its own type-safe return value and documentation, and make it easy to delete an old version later.