00:00

Spring Boot REST API Versioning

Last updated:

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:

Two versions side by side
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:

ChangeBreaking?Needs a new version?
Adding a new optional field to a responseNoNo
Adding a new endpointNoNo
Adding an optional request fieldNoNo
Removing or renaming a response fieldYesYes
Changing a field’s type, such as a number to textYesYes
Making an optional request field requiredYesYes
Changing the meaning of a value or status codeYesYes

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:

CustomerV1.java
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());
    }
}
CustomerV2.java
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

StrategyWhere the version goesExample
URI pathIn the URL pathGET /api/v2/customers/7
Request parameterIn the query stringGET /api/customers/7?version=2
HeaderIn a custom request headerX-API-Version: 2
Media typeIn the Accept headerAccept: 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:

CustomerControllerV1.java
@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));
    }
}
CustomerControllerV2.java
@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));
    }
}
ProsCons
Easy to see, use and test, even in a browserThe URL changes for each version
Simple routing in gateways and load balancersSome argue a resource should keep one URL
Easy to document separately per versionClients must update URLs to upgrade

6. Request Parameter Versioning

The version is a query parameter, and Spring chooses the method with the params attribute:

CustomerController.java
@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));
    }
}
ProsCons
The resource URL stays the sameMixes versioning with query parameters meant for filtering
Easy to try in a browserEasy to forget; a request without version matches nothing and fails

The client sends the version in a custom header, and Spring matches it with the headers attribute:

CustomerController.java
@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));
}
Terminal
curl -H "X-API-Version: 2" http://localhost:8080/api/customers/7
ProsCons
Clean, stable URLsNot visible in the URL, so harder to spot in logs and links
Version is separate from the resourceCannot be tested by typing a URL in a browser
Works well with API gatewaysCaches 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:

CustomerController.java
@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));
}
Terminal
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.

ProsCons
Versions the representation, not the resource: closest to REST principlesThe most complex for clients to use
Different resources can evolve independentlyHard to test manually and to document

9. Comparing the Strategies

URI pathRequest parameterHeaderMedia type
Visible in the URLYesYesNoNo
Easy to test in a browserYesYesNoNo
Stable resource URLNoYesYesYes
Simple HTTP cachingYesYesNeeds VaryNeeds Vary
Ease for clientsVery easyEasyMediumHarder
PopularityMost commonLess commonCommonLess 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:

application.properties
spring.mvc.apiversion.use.header=X-API-Version
spring.mvc.apiversion.default=1.0

Or configure it in code, where more options are available:

ApiVersionConfig.java
@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 methodReads 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

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

BehaviorResult
Version missing, and no default set400 Bad Request (a version is required by default)
Version missing, default setThe default version is used
Version not supported400 Bad Request
Methods without a versionMatch 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:

Where versions live
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.

CustomerControllerV1.java
@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:

  1. Announce the deprecation in your documentation and changelog.
  2. Signal it in responses with standard headers, so clients notice automatically.
  3. Measure usage of the old version, and contact the remaining clients.
  4. 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:

ApiVersionConfig.java
@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:

CustomerControllerV1.java
@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:

OpenApiConfig.java
@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:

CustomerVersioningTest.java
@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?

StrategyBest for
URI pathPublic APIs, beginners and most teams; the safest default
Request parameterSimple or internal APIs where URLs must stay fixed
HeaderAPIs behind gateways with controlled, well-documented clients
Media typeLarge APIs where resources evolve independently
Spring Framework 7 built-inNew 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.