Spring Boot

Content Negotiation

How Spring Boot 3 picks the response format from the Accept header or a query parameter: JSON and XML setup, produces and consumes, 406 and 415 errors, a custom CSV HttpMessageConverter, YAML output, and API versioning with vendor media types. Includes the XML dependency most tutorials forget and the pitfalls that cause wrong formats or ambiguous mappings.

How Spring Resolves Media Types

In Spring Framework 6 (Spring Boot 3), content negotiation uses two strategies, evaluated in order: (1) a request parameter such as ?format=json, which is off by default, and (2) the Accept header. If neither produces a specific type (no Accept header, or Accept: */*), the configured defaultContentType is used. Path extensions such as /users/1.json were removed in Spring 6, so older tutorials that call favorPathExtension no longer apply. Once the requested types are known, Spring intersects them with the types the handler can produce, honours quality values (q=), and picks the first HttpMessageConverter that can write the winning type. The converter list only contains XML support if the Jackson XML module is on the classpath (see the next section).
Java
// ── Default resolution (Accept header only) ──────────────────────────
// GET /users/1
// Accept: application/json                → JSON
// Accept: application/xml                 → XML  (needs jackson-dataformat-xml)
// Accept: application/xml;q=0.5, application/json → JSON (higher q wins)
// Accept: */* or no Accept header         → defaultContentType (JSON below)

// ── Enable parameter-based negotiation ───────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .favorParameter(true)               // ?format=json or ?format=xml
            .parameterName("format")
            .ignoreAcceptHeader(false)           // Accept header still respected
            .defaultContentType(MediaType.APPLICATION_JSON)
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml",  MediaType.APPLICATION_XML)
            .mediaType("csv",  new MediaType("text", "csv"));
    }
}

// ── Example requests ──────────────────────────────────────────────────
// GET /users/1?format=xml              → XML  (parameter wins over Accept)
// GET /users/1?format=json             → JSON
// GET /users/1                         → JSON (default)
// GET /users/1  Accept: application/xml → XML (Accept header strategy)

Adding XML Support

Spring Boot serializes JSON out of the box, but XML needs one extra dependency. Without it, Accept: application/xml returns 406 Not Acceptable, because no converter can write XML. Once the module is present, Boot registers the XML converter automatically. A side effect: with both converters registered, requests that accept anything (browsers send Accept: text/html,*/*) can start returning XML. Pin JSON as the default with defaultContentType, as shown above, or list JSON first in produces.
XML
<!-- pom.xml -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
</dependency>

produces and consumes on Mappings

The produces attribute restricts which media types a handler will serve, and the server answers 406 Not Acceptable when the client's Accept header does not match. The consumes attribute restricts which Content-Type a handler accepts for request bodies, and the server answers 415 Unsupported Media Type on mismatch. Quick rule: 406 is about what the client wants back, 415 is about what the client sent. Both can be set at class or method level, and a method-level value replaces the class-level one rather than merging with it. When you list several types, put the one you want as the default first.
Java
@RestController
@RequestMapping(
    value = "/api/v1/products",
    produces = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE
    }
)
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    // ── Inherits class-level produces (JSON + XML) ────────────────────
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return productService.findById(id);
    }

    // ── Override: this endpoint is JSON-only ──────────────────────────
    @GetMapping(value = "/search",
                produces = MediaType.APPLICATION_JSON_VALUE)
    public Page<ProductResponse> search(@RequestParam String q, Pageable pageable) {
        return productService.search(q, pageable);
    }

    // ── Accept JSON or XML request bodies ─────────────────────────────
    @PostMapping(
        consumes = {
            MediaType.APPLICATION_JSON_VALUE,
            MediaType.APPLICATION_XML_VALUE
        }
    )
    public ResponseEntity<ProductResponse> create(
            @RequestBody @Valid CreateProductRequest request,
            UriComponentsBuilder uriBuilder) {
        ProductResponse created = productService.create(request);
        URI location = uriBuilder
            .path("/api/v1/products/{id}")
            .buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    // ── CSV export — single custom media type ─────────────────────────
    @GetMapping(value = "/export", produces = "text/csv")
    public ResponseEntity<String> export() {
        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                        .filename("products.csv", StandardCharsets.UTF_8)
                        .build().toString())
            .body(productService.exportCsv());
    }
}

Registering a Custom HttpMessageConverter

Register a custom HttpMessageConverter to support any media type Spring does not handle out of the box, such as CSV or Protocol Buffers. Extend AbstractHttpMessageConverter, declare the supported media types, and register it through WebMvcConfigurer. Four details matter for a safe converter: disable reading if you only write (otherwise a POST with Content-Type: text/csv reaches readInternal and fails with a 500), escape CSV values (commas, quotes, line breaks), neutralise values that start with = + - or @ so spreadsheets do not run them as formulas, and flush the output stream instead of closing it, because the response belongs to the container.
Java
// ── CSV converter for any List payload ───────────────────────────────
public class CsvHttpMessageConverter
        extends AbstractHttpMessageConverter<List<?>> {

    public CsvHttpMessageConverter() {
        super(new MediaType("text", "csv", StandardCharsets.UTF_8));
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return List.class.isAssignableFrom(clazz);
    }

    // ── Write-only converter: never claim to read ─────────────────────
    @Override
    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        return false;
    }

    @Override
    protected List<?> readInternal(Class<? extends List<?>> clazz,
                                   HttpInputMessage inputMessage) {
        throw new UnsupportedOperationException("CSV read not supported");
    }

    @Override
    protected void writeInternal(List<?> items,
                                 HttpOutputMessage outputMessage) throws IOException {
        Writer writer = new OutputStreamWriter(
            outputMessage.getBody(), StandardCharsets.UTF_8);

        if (!items.isEmpty()) {
            // Header row from field names (works well with records)
            Field[] fields = Arrays.stream(
                    items.get(0).getClass().getDeclaredFields())
                .filter(f -> !Modifier.isStatic(f.getModifiers()) && !f.isSynthetic())
                .toArray(Field[]::new);
            for (Field f : fields) f.setAccessible(true);

            writer.write(Arrays.stream(fields)
                .map(Field::getName)
                .collect(Collectors.joining(",")) + "\r\n");

            for (Object item : items) {
                writer.write(Arrays.stream(fields)
                    .map(f -> {
                        try { return escape(f.get(item)); }
                        catch (IllegalAccessException e) { return ""; }
                    })
                    .collect(Collectors.joining(",")) + "\r\n");
            }
        }
        writer.flush();   // flush, do not close the response stream
    }

    private String escape(Object value) {
        if (value == null) return "";
        String text = String.valueOf(value);
        if (value instanceof String && !text.isEmpty()
                && "=+-@".indexOf(text.charAt(0)) >= 0) {
            text = "'" + text;   // neutralise spreadsheet formula injection
        }
        if (text.contains(",") || text.contains("\"")
                || text.contains("\n") || text.contains("\r")) {
            text = "\"" + text.replace("\"", "\"\"") + "\"";
        }
        return text;
    }
}

// ── Register the converter ────────────────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(new CsvHttpMessageConverter());
    }
}

// ── Controller — no extra code needed ────────────────────────────────
@GetMapping(produces = {"application/json", "text/csv"})
public List<UserResponse> findAll() {
    return userService.findAll();
    // Accept: application/json → JSON
    // Accept: text/csv         → CSV via CsvHttpMessageConverter
    // ?format=csv              → CSV (needs the csv mapping from the first section)
}

YAML Support via Custom Converter

Adding YAML follows the same pattern. Jackson's YAML module provides the serializer, and a thin converter bridges it into Spring's negotiation pipeline. By default Jackson writes a leading --- marker and quotes every string, so the example turns both off for cleaner output. Boot picks up any HttpMessageConverter bean automatically, so a @Component is enough. This setup is for responses; only add application/yaml to consumes if you also want to accept YAML request bodies.
XML
<!-- pom.xml -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-yaml</artifactId>
</dependency>

YAML Converter and Controller

The converter extends AbstractJackson2HttpMessageConverter with a YAMLMapper and the YAML media types. The JavaTimeModule is included in Spring Boot's JSON starter, and the date feature below keeps dates readable instead of numeric timestamps.
Java
// ── YAML message converter ────────────────────────────────────────────
@Component
public class YamlHttpMessageConverter
        extends AbstractJackson2HttpMessageConverter {

    public YamlHttpMessageConverter() {
        super(
            YAMLMapper.builder()
                .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
                .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .addModule(new JavaTimeModule())
                .build(),
            new MediaType("application", "yaml"),
            new MediaType("text", "yaml"));
    }
}

// ── Controller ────────────────────────────────────────────────────────
@GetMapping(
    value = "/{id}",
    produces = {
        MediaType.APPLICATION_JSON_VALUE,
        "application/yaml"
    }
)
public UserResponse findById(@PathVariable Long id) {
    return userService.findById(id);
    // Accept: application/json → {"id":1,"name":"Alice"}
    // Accept: application/yaml → one "key: value" pair per line (id: 1, name: Alice)
}

Versioning via Media Type (Vendor MIME)

Vendor media types (application/vnd.myapp.user.v2+json) version a REST API without changing the URL. The client states the version in the Accept header, and Spring routes to the matching handler through produces. Jackson writes any application/*+json type, so no extra converter is needed. One thing to test: a request with no Accept header, or Accept: */*, matches every versioned mapping on the same path. Depending on the Spring version this can fail with an ambiguous handler mapping instead of falling back to the default. Always call the endpoint with plain curl and no Accept header. If it fails, use the single-handler version below, which makes the default explicit.
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // ── V1 ────────────────────────────────────────────────────────────
    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.user.v1+json"
    )
    public UserResponseV1 findByIdV1(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }

    // ── V2 — richer response, different field names ───────────────────
    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.user.v2+json"
    )
    public UserResponseV2 findByIdV2(@PathVariable Long id) {
        return userService.findByIdV2(id);
    }
}

// ── Example requests ──────────────────────────────────────────────────
// Accept: application/vnd.myapp.user.v1+json → UserResponseV1
// Accept: application/vnd.myapp.user.v2+json → UserResponseV2

// ── Single handler with an explicit default (no ambiguity) ────────────
private static final MediaType V1 = MediaType.valueOf("application/vnd.myapp.user.v1+json");
private static final MediaType V2 = MediaType.valueOf("application/vnd.myapp.user.v2+json");

@GetMapping(
    value = "/{id}",
    produces = {
        "application/vnd.myapp.user.v2+json",
        "application/vnd.myapp.user.v1+json",
        MediaType.APPLICATION_JSON_VALUE
    }
)
public ResponseEntity<?> findById(@PathVariable Long id,
                                  @RequestHeader HttpHeaders headers) {
    boolean wantsV1 = headers.getAccept().stream()
        .anyMatch(V1::equalsTypeAndSubtype);

    return wantsV1
        ? ResponseEntity.ok().contentType(V1).body(userService.findByIdV1(id))
        : ResponseEntity.ok().contentType(V2).body(userService.findByIdV2(id));
}

Handling 406 Not Acceptable and 415

When no converter can satisfy the Accept header, Spring throws HttpMediaTypeNotAcceptableException and returns 406 with an empty body. A wrong request Content-Type raises HttpMediaTypeNotSupportedException and returns 415. Handle both in a @RestControllerAdvice to return a consistent JSON error that lists the supported types. Set the response content type explicitly, otherwise the error body itself would be negotiated against the same Accept header that just failed.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
    public ResponseEntity<ErrorResponse> handleNotAcceptable(
            HttpMediaTypeNotAcceptableException ex,
            HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE)
            .contentType(MediaType.APPLICATION_JSON)
            .body(ErrorResponse.of(
                406,
                "Not Acceptable",
                "Supported media types: " + join(ex.getSupportedMediaTypes()),
                req.getRequestURI()));
    }

    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleNotSupported(
            HttpMediaTypeNotSupportedException ex,
            HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
            .contentType(MediaType.APPLICATION_JSON)
            .body(ErrorResponse.of(
                415,
                "Unsupported Media Type",
                "Supported media types: " + join(ex.getSupportedMediaTypes()),
                req.getRequestURI()));
    }

    private String join(List<MediaType> types) {
        return types.stream()
            .map(MediaType::toString)
            .collect(Collectors.joining(", "));
    }
}

Common Mistakes

1. Expecting XML without jackson-dataformat-xml. The result is a 406, not an XML response. 2. Adding the XML module and then getting XML for browsers and */* requests. Set defaultContentType(JSON) or list JSON first in produces. 3. Following older tutorials that use favorPathExtension or URLs like /users/1.json. Path-extension negotiation was removed in Spring 6. Use the Accept header or the format parameter. 4. Confusing 406 and 415. 406 means the server cannot produce what the Accept header asked for. 415 means the server cannot read the Content-Type the client sent. 5. A converter that claims canRead for types it cannot read. A POST in that format then fails with a 500 instead of a clean 415. 6. Closing the response stream inside a converter. Flush it and let the container close it. 7. Unescaped CSV values and formula injection. Names with commas shift columns, and a value like =HYPERLINK(...) can run in a spreadsheet. 8. Versioned mappings on the same path with no tested default. Call the endpoint without an Accept header before shipping. 9. Forgetting that method-level produces replaces class-level produces instead of adding to it.

FAQ

How does Spring Boot decide between JSON and XML? It reads the Accept header (or the format parameter if enabled), intersects it with what the handler can produce, and picks the first converter that can write the best match. With no preference, defaultContentType decides. How do I make Spring Boot return XML? Add jackson-dataformat-xml to the classpath, then send Accept: application/xml or list application/xml in produces. What is the difference between produces and consumes? produces controls the response type and is checked against the Accept header (406 on mismatch). consumes controls the request body type and is checked against Content-Type (415 on mismatch). How do I support a format Spring does not know, such as CSV or YAML? Write an HttpMessageConverter for the media type and register it with extendMessageConverters or as a bean. Content negotiation then selects it automatically. Can I choose the format from the URL? Yes. Enable favorParameter(true) and call /users/1?format=xml. Path-extension URLs are no longer supported in Spring 6. Is media type versioning better than URL versioning? It keeps URLs stable and is the purist REST approach, but it is harder to test in a browser and to cache. URL versioning (/api/v2/users) is simpler to debug. Pick one and apply it consistently.