Jackson 3 in Spring Boot 4
Spring Boot 4 moves to Jackson 3: new tools.jackson packages, renamed types, immutable mappers, unchecked exceptions and some changed defaults.
import tools.jackson.databind.json.JsonMapper; // databind: new package
import com.fasterxml.jackson.annotation.JsonProperty; // annotations: unchangedSpring Boot 4 and Spring Framework 7 are built on Jackson 3. Most of your DTOs keep working untouched, because the annotations you use every day (@JsonProperty, @JsonInclude, @JsonFormat, @JsonIgnore) still live in com.fasterxml.jackson.annotation. What changes is everything else: packages, class names, how a mapper is configured and a handful of defaults.
New packages and names
Core and databind moved from com.fasterxml.jackson to tools.jackson. Several types were renamed at the same time:
| Spring Boot 3 / Jackson 2 | Spring Boot 4 / Jackson 3 |
|---|---|
JsonSerializer, JsonDeserializer | ValueSerializer, ValueDeserializer |
SerializerProvider | SerializationContext |
Module | JacksonModule |
@JsonComponent | @JacksonComponent |
Jackson2ObjectMapperBuilderCustomizer | JsonMapperBuilderCustomizer |
MappingJackson2HttpMessageConverter | JacksonJsonHttpMessageConverter |
gen.writeStringField(...) | gen.writeStringProperty(...) |
Watch the databind annotations: @JsonSerialize, @JsonDeserialize and @JsonNaming are now in tools.jackson.databind.annotation, while the core annotations did not move. Search your code base for com.fasterxml.jackson.databind; anything left is a compile error waiting to happen.
Immutable mappers and unchecked exceptions
A Jackson 3 ObjectMapper cannot be reconfigured after it is built: there is no configure() or registerModule(). All settings go through JsonMapper.builder(), which is why Spring Boot exposes builder customisers. The upside is that a shared mapper can no longer be changed by one careless call at runtime.
JacksonException now extends RuntimeException. Code that wrapped every writeValueAsString in try/catch (JsonProcessingException e) can drop the boilerplate, but catch JacksonException deliberately where bad input is expected, such as webhook payloads.
Defaults that changed
Support for java.time is built in, so the separate JSR-310 module is gone. Dates are written as ISO strings by default. Properties of ordinary classes are sorted alphabetically, and a null for a primitive is rejected. If an upgrade changes your responses and you need time to adapt clients, spring.jackson.use-jackson2-defaults=true configures Jackson 3 with the defaults Spring Boot used for Jackson 2. Treat it as a bridge, not a destination.
Example
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.module.SimpleModule;
record Temperature(double celsius) {}
// Jackson 2: extends JsonSerializer<T> and takes a SerializerProvider.
class TemperatureSerializer extends ValueSerializer<Temperature> {
public void serialize(Temperature value, JsonGenerator gen, SerializationContext context) {
gen.writeStartObject();
gen.writeNumberProperty("celsius", value.celsius());
gen.writeStringProperty("unit", "C");
gen.writeEndObject();
}
}
(proxyBeanMethods = false)
public class JsonConfig {
// Jackson 2: Jackson2ObjectMapperBuilderCustomizer.
JsonMapperBuilderCustomizer temperatureCustomizer() {
return builder -> builder.addModule(
new SimpleModule("temperature").addSerializer(Temperature.class, new TemperatureSerializer()));
}
}When to use it
- Upgrade a Spring Boot 3 service whose custom serialisers extend JsonSerializer and fail to compile on Boot 4.
- Keep a partner integration's byte-for-byte JSON stable for one release while the upgrade lands.
- Remove the try/catch boilerplate around writeValueAsString that Jackson 2's checked exceptions forced on every caller.
More examples
Import changes at a glance
Work through this list with your IDE's organise-imports rather than a blind text replacement. The last line is the trap: the annotations package did not change, and the Jackson 3 annotations jar still provides it.
Jackson 2 (Spring Boot 3) Jackson 3 (Spring Boot 4)
com.fasterxml.jackson.databind.ObjectMapper tools.jackson.databind.ObjectMapper
com.fasterxml.jackson.databind.json.JsonMapper tools.jackson.databind.json.JsonMapper
com.fasterxml.jackson.databind.JsonSerializer tools.jackson.databind.ValueSerializer
com.fasterxml.jackson.databind.JsonDeserializer tools.jackson.databind.ValueDeserializer
com.fasterxml.jackson.databind.SerializerProvider tools.jackson.databind.SerializationContext
com.fasterxml.jackson.databind.annotation.JsonNaming tools.jackson.databind.annotation.JsonNaming
com.fasterxml.jackson.core.JsonProcessingException tools.jackson.core.JacksonException (unchecked)
org.springframework.boot.jackson.JsonComponent org.springframework.boot.jackson.JacksonComponent
com.fasterxml.jackson.annotation.JsonProperty com.fasterxml.jackson.annotation.JsonProperty (same)Keeping the old defaults during an upgrade
Use this when an upgrade changes response bodies that a mobile app or partner integration compares byte for byte. Plan to remove it: write tests that pin the JSON you actually want, then switch the flag off and fix the differences one by one.
# Configure Jackson 3 with the defaults Spring Boot used with Jackson 2:
# properties of plain classes keep declaration order, null for a primitive
# becomes 0 again, java.util.Date keeps its old offset format.
spring.jackson.use-jackson2-defaults=trueUnchecked exceptions when parsing untrusted input
Because <code>JacksonException</code> is a <code>RuntimeException</code>, the compiler no longer reminds you that parsing can fail. Catch it on purpose at the boundaries where input is untrusted, and let it propagate where a failure really is a bug.
import java.util.Optional;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
record WebhookEvent(String type, String id) {}
@Component
public class WebhookParser {
private final JsonMapper jsonMapper;
WebhookParser(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}
Optional<WebhookEvent> parse(String payload) {
try {
return Optional.of(jsonMapper.readValue(payload, WebhookEvent.class));
} catch (JacksonException e) {
// Bad payloads are expected from the outside world; do not let them become a 500.
return Optional.empty();
}
}
}
Discussion