Jackson and Content Negotiation: Controlling the JSON You Emit
How Spring picks a message converter, the Jackson annotations that shape your JSON, custom serialisers, and why polymorphic deserialisation needs care.
The Layer You Only Notice When It's Wrong
Jackson is invisible until a date comes out as [2026,10,8], a lazy JPA association throws during serialisation, a null appears in every response for fields nobody cares about, or a client complains that customerName arrived as customer_name.
All of those are configuration, and all of them are better fixed at the serialisation layer than worked around in controllers.
Content Negotiation: Choosing a Representation
From guide 1, @ResponseBody routes the return value through the HttpMessageConverter chain. Which converter runs depends on content negotiation — matching what the client will accept against what the endpoint can produce.
By default only the Accept header is consulted. Spring can also negotiate on a URL parameter, which is useful for browser-testable endpoints:
spring:
mvc:
contentnegotiation:
favor-parameter: true
parameter-name: format # /api/orders?format=xmlAdd jackson-dataformat-xml to the classpath and the same controller serves XML, chosen by Accept. That's the starter model from phase 1 again: a dependency becomes a capability, no controller change.
Two status codes, often confused. 415 Unsupported Media Type — the request body's Content-Type doesn't match the endpoint's consumes; the server can't read what you sent. 406 Not Acceptable — the endpoint can't produce anything in the Accept header. 415 is about input, 406 about output.
Shaping JSON With Annotations
The annotations that cover most needs:
public record OrderResponse(
UUID id,
@JsonProperty("customer_name") // rename on the wire
String customerName,
@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
Instant placedAt,
@JsonInclude(JsonInclude.Include.NON_NULL) // omit when null
String note,
@JsonIgnore // never serialise
String internalAuditToken,
BigDecimal total) {
}| Annotation | Effect |
|---|---|
@JsonProperty("name") | Rename a field on the wire |
@JsonIgnore | Exclude entirely, both directions |
@JsonIgnoreProperties({...}) | Exclude several, at type level |
@JsonInclude(NON_NULL) | Omit when null — field or type level |
@JsonFormat | Control date/number rendering |
@JsonAlias({...}) | Accept alternative names when reading |
@JsonAnyGetter / @JsonAnySetter | Map dynamic key/value pairs |
@JsonCreator | Nominate a constructor or factory for deserialisation |
@JsonUnwrapped | Flatten a nested object into the parent |
Records need no @JsonCreator in current Jackson — the canonical constructor is detected. For classes, a single constructor is also used automatically; @JsonCreator is for disambiguating among several.
Prefer global settings to repeated annotations
Annotating @JsonInclude(NON_NULL) on forty records is forty chances to miss one. Set the policy once:
spring:
jackson:
default-property-inclusion: non_null
property-naming-strategy: SNAKE_CASE # customerName → customer_name everywhere
serialization:
write-dates-as-timestamps: false
fail-on-empty-beans: false
deserialization:
fail-on-unknown-properties: false
time-zone: UTCproperty-naming-strategy: SNAKE_CASE is the one to reach for when a client expects snake_case — far better than @JsonProperty on every field.
fail-on-unknown-properties is false by default in Spring Boot, so unexpected fields in a request body are silently ignored. That's deliberate — it lets clients add fields without breaking you — but it also means a client sending quantityy gets a 200 and an order with the default quantity. For request DTOs where typos should be caught, set it true and accept the tighter coupling. Decide consciously rather than inheriting the default unexamined.
Dates: The Default Worth Changing
Out of the box, Jackson writes java.time values as numeric arrays or epoch values:
{ "placedAt": 1760000062.116000000 }Technically lossless, awkward for every client. Turn it off:
spring:
jackson:
serialization:
write-dates-as-timestamps: falseAnd you get ISO-8601:
{ "placedAt": "2026-10-08T09:14:22.116Z" }Spring Boot registers JavaTimeModule automatically when jackson-datatype-jsr310 is present, which starter-web brings in — so Instant, LocalDate and friends work without setup. The only thing you usually change is that timestamps property.
Use Instant or OffsetDateTime on API boundaries, not LocalDateTime. LocalDateTime has no zone, so "2026-10-08T09:14:22" is ambiguous the moment a client in another zone reads it. Store and transmit instants; convert to local time at the edge where you know the user's zone.
Customising Without Replacing the ObjectMapper
From phase 1: define your own ObjectMapper bean and JacksonAutoConfiguration backs off entirely — which means every spring.jackson.* property stops working, because the auto-configuration that reads them no longer runs. This is the single most common way to break Jackson configuration, and the condition report shows it plainly.
Three seams, in order of preference.
1. Properties. Covered above. Visible, per-profile, and nothing to maintain.
2. A customiser. Adjusts the auto-configured mapper rather than owning it:
@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializerByType(Money.class, new MoneySerializer())
.featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
}3. A module. For reusable type support, especially across services:
@Bean
Module moneyModule() {
SimpleModule module = new SimpleModule("money");
module.addSerializer(Money.class, new MoneySerializer());
module.addDeserializer(Money.class, new MoneyDeserializer());
return module;
}Any Module bean is picked up and registered automatically. This is the cleanest option for domain types — the knowledge of how Money serialises lives with Money, not in a configuration class.
Replacing the ObjectMapper outright is a last resort. If you must, be aware you now own every default Spring Boot was setting for you.
Check yourself
A team sets spring.jackson.property-naming-strategy: SNAKE_CASE, but responses still use camelCase. The --debug report shows JacksonAutoConfiguration#jacksonObjectMapper did not match: '@ConditionalOnMissingBean found beans of type ObjectMapper'. What is the fix?
Custom Serialisers
When a type needs a representation Jackson can't infer:
public class MoneySerializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
gen.writeStartObject();
gen.writeStringField("amount", value.amount().toPlainString());
gen.writeStringField("currency", value.currency().getCurrencyCode());
gen.writeEndObject();
}
}public class MoneyDeserializer extends JsonDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.readValueAsTree();
return new Money(
new BigDecimal(node.get("amount").asText()),
Currency.getInstance(node.get("currency").asText()));
}
}Register via a module as above, or point at them directly with @JsonSerialize(using = MoneySerializer.class).
Note the money being written as a string, not a number. That is deliberate: JSON numbers are IEEE-754 doubles in most parsers, so 10.10 can arrive as 10.099999999999999 in JavaScript. Serialise monetary amounts as strings and parse them into exact types. For the same reason, consider write-bigdecimal-as-plain: true to avoid scientific notation.
Polymorphism, Carefully
Serialising a sealed hierarchy needs a discriminator so deserialisation knows which subtype to build:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CardPayment.class, name = "card"),
@JsonSubTypes.Type(value = BankTransfer.class, name = "bank_transfer")
})
public sealed interface PaymentMethod permits CardPayment, BankTransfer { }{ "type": "card", "last4": "4242", "expiryMonth": 11 }Use JsonTypeInfo.Id.NAME with an explicit @JsonSubTypes allow-list. Do not enable Jackson's default typing (ObjectMapper.enableDefaultTyping, or Id.CLASS/Id.MINIMAL_CLASS on untrusted input): it lets the JSON payload name an arbitrary Java class to instantiate, which is the root of a long series of Jackson deserialisation CVEs. An attacker who controls a type name can reach gadget classes on your classpath and achieve remote code execution. An explicit allow-list is both safer and self-documenting.
Entities Are Not Response Bodies
The most consequential rule in this guide, and it isn't a Jackson setting.
@GetMapping("/{id}")
Order findById(@PathVariable UUID id) { // returning a JPA entity
return repository.findById(id).orElseThrow();
}Four distinct problems:
1. Lazy loading explodes. Jackson walks every getter. A LAZY association outside a transaction throws LazyInitializationException mid-serialisation — after the response has started, producing a truncated body with a 200 status.
2. Bidirectional relationships recurse. Order → List<OrderLine> → Order is infinite. @JsonManagedReference/@JsonBackReference or @JsonIgnore patch it; a separate response type removes it.
3. Everything leaks by default. Add an internal column to the entity and it silently appears in your public API. The default is expose-everything, and @JsonIgnore is an opt-out you have to remember every time.
4. Your schema becomes your API. Rename a column and you break clients. The database schema and the API contract should be free to evolve separately.
A response record fixes all four:
public record OrderResponse(UUID id, String customerName, BigDecimal total, Instant placedAt) {
static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getCustomer().getName(),
order.getTotal(), order.getPlacedAt());
}
}The mapping method is the whole cost, and it buys an explicit allow-list: a field reaches a client only because someone wrote it down. The same argument applied to request bodies in guide 1 — a DTO is a whitelist in both directions.
Check yourself
An endpoint returns a JPA entity with a LAZY @OneToMany. The client sees a 200 with a truncated JSON body, and the log shows LazyInitializationException. Why the 200?
A Note on Jackson 3
Spring Boot 4 ships Jackson 3, whose most visible change is a package rename — the Jackson 3 core and databind packages moved out of com.fasterxml.jackson. Annotations stayed put, so the annotation-heavy code above is unaffected, but imports of ObjectMapper, JsonSerializer and similar types do change.
If you are on Boot 3.x you're on Jackson 2 and nothing here needs adjusting. On Boot 4, expect this to be the most mechanical part of the upgrade; our Spring Boot 4 write-up covers it alongside the rest. The concepts — converters, negotiation, modules, annotations — are unchanged.
The Mental Model, Restated
- Content negotiation picks the converter. 415 is about the request body, 406 about the
Acceptheader. - Prefer global properties to per-field annotations for anything that should be consistent.
- Turn off numeric timestamps and use
Instant/OffsetDateTimeat the boundary. - Customise with a customiser or module, not a replacement
ObjectMapper— replacing it silently disables everyspring.jackson.*property. - Serialise money as a string. JSON numbers are doubles in most parsers.
- Allow-list polymorphic subtypes. Never enable default typing on untrusted input.
- Never return JPA entities. Lazy loading, recursion, accidental exposure, and schema coupling — all solved by a response record.
What's Next
Everything so far has been inbound: a request arrives, you answer it. Real services also make calls — to a payment provider, an inventory service, a third-party API. The final guide in this phase covers outbound HTTP with RestClient, where WebClient is still the right choice, the timeout and retry settings that matter more than the client you pick, and what to do about RestTemplate.