After upgrading to Spring Boot 4, a custom Jackson 2 serializer that worked on Spring Boot 3 is never called. Adding spring-boot-jackson2 to keep Jackson 2 doesn’t change anything. There’s no exception and no warning; the response JSON just changes shape.
Spring Boot 4 uses Jackson 3 (
JacksonJsonHttpMessageConverter) for HTTP message conversion, so your Jackson 2Modulebeans never touch the response.
With both Jackson 3 and Jackson 2 on the classpath, setspring.http.converters.preferred-json-mapper=jackson2to select the Jackson 2 converter.
Even if you exclude the Jackson 3 starter,spring-boot-starter-webmvc-testbrings Jackson 3 back in test scope, so keep the property anyway.
Environment
I put the same controller and Jackson 2 module on Spring Boot 3.5.16 and 4.1.2 and compared them two ways: a MockMvc test, and java -jar plus a curl call.
| Item | Version |
|---|---|
| JDK | 21.0.12.1 (Temurin) |
| Maven | 3.10.0 |
| Spring Boot (reproduce and fix) | 4.1.2 |
| Jackson 3 (Boot 4.1.2 default) | tools.jackson.core:jackson-databind 3.1.7 |
Jackson 2 (spring-boot-jackson2 4.1.2) |
com.fasterxml.jackson.core:jackson-databind 2.21.7 |
| Spring Boot (comparison) | 3.5.16 |
Reproduce: MoneySerializer isn’t called even with spring-boot-jackson2
The example is a Jackson 2 serializer that writes the price in an order response as the string "15000 KRW". A print line shows whether it runs.
// Jackson 2 serializer: writes Money as "15000 KRW"
public class MoneySerializer extends StdSerializer<Money> {
public MoneySerializer() {
super(Money.class);
}
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider provider) throws IOException {
System.out.println(">>> Jackson 2 MoneySerializer called");
gen.writeString(value.amount() + " " + value.currency());
}
}
Every import here comes from the com.fasterxml.jackson packages. The serializer goes into a SimpleModule, which is registered as a Module bean.
// Jackson 2 module that registers MoneySerializer
public class CustomModule extends SimpleModule {
public CustomModule() {
super("CustomModule");
addSerializer(Money.class, new MoneySerializer());
}
}
@Configuration
public class JacksonConfig {
// Spring Boot registers Jackson 2 Module beans with the auto-configured ObjectMapper
@Bean
Module customModule() {
return new CustomModule();
}
}
The controller returns new OrderResponse(id, new Money(15000, "KRW")). The test prints the JSON converters registered with MVC, then checks that $.price is "15000 KRW".
@Test
void customSerializerIsApplied() throws Exception {
handlerAdapter.getMessageConverters().stream()
.map(c -> c.getClass().getName())
.filter(n -> n.contains("Json") || n.contains("Jackson"))
.forEach(n -> System.out.println(">>> converter: " + n));
mockMvc.perform(get("/orders/1"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.price").value("15000 KRW"));
}
On Spring Boot 3.5.16 the test passes. The converter is MappingJackson2HttpMessageConverter, and the serializer runs.
$ mvn -B test
...
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> Jackson 2 MoneySerializer called
...
Body = {"id":1,"price":"15000 KRW"}
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.781 s -- in com.example.order.OrderControllerTest
For the Spring Boot 4.1.2 version, I replaced spring-boot-starter-web with spring-boot-starter-webmvc and added spring-boot-jackson2 so the Jackson 2 code would keep working.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- keep Jackson 2 (deprecated module) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson2</artifactId>
</dependency>
It compiles. The test fails, though. The only JSON converter registered is JacksonJsonHttpMessageConverter, and MoneySerializer called never shows up.
$ mvn -B test -Dtest=OrderControllerTest
...
>>> converter: org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
...
Body = {"id":1,"price":{"amount":15000,"currency":"KRW"}}
...
java.lang.AssertionError: JSON path "$.price" expected:<15000 KRW> but was:<null>
price came out as an object built from the record fields instead of a string. This isn’t a test-only problem. Run the jar and call it with curl, and you get the same response.
$ curl -s http://localhost:18081/orders/1
{"id":1,"price":{"amount":15000,"currency":"KRW"}}
Since nothing throws, the logs won’t point you at it. Often the first sign is a client team reporting that the response format changed.
Cause: the default JSON converter in Spring Boot 4 is Jackson 3
The dependency tree shows two copies of Jackson. spring-boot-starter-webmvc pulls in Jackson 3 through spring-boot-starter-jackson, and spring-boot-jackson2 pulls in Jackson 2.
$ mvn -B dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind,tools.jackson.core:jackson-databind
...
[INFO] +- org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.2:compile
[INFO] | \- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.2:compile
[INFO] | \- org.springframework.boot:spring-boot-jackson:jar:4.1.2:compile
[INFO] | \- tools.jackson.core:jackson-databind:jar:3.1.7:compile
[INFO] \- org.springframework.boot:spring-boot-jackson2:jar:4.1.2:compile
[INFO] \- com.fasterxml.jackson.core:jackson-databind:jar:2.21.7:compile
The Spring Boot 4 reference documentation calls Jackson 3 "the preferred and default library." spring-boot-jackson2 is a deprecated module that creates a Jackson 2 ObjectMapper bean, nothing more. Which mapper MVC uses for responses is a separate decision.
That decision is controlled by spring.http.converters.preferred-json-mapper. The properties appendix describes the default as auto-detected from the environment, and lists the values jackson, jackson2 (deprecated), gson, and jsonb. The reference documentation tells you to set it to jackson2 when Jackson 3 and Jackson 2 are both present.

Your Jackson 2 Module bean does get registered with the Jackson 2 ObjectMapper. But no MVC converter uses that ObjectMapper, so the module has no visible effect. On Spring Boot 3.5.16 the only JSON converter was MappingJackson2HttpMessageConverter, so there was nothing to choose.
Fix: preferred-json-mapper=jackson2 or a port to Jackson 3
You have two options. Use fix 1 if you need to stay on Jackson 2 for now. Use fix 2 if you only have a handful of serializers.
Fix 1: set spring.http.converters.preferred-json-mapper=jackson2
Keep both Jackson 3 and Jackson 2 on the classpath and add one property. In the lab I set it on the test class; in a real app, put the same key in application.properties.
@SpringBootTest(properties = "spring.http.converters.preferred-json-mapper=jackson2")
@AutoConfigureMockMvc
class PreferredJackson2Test extends AbstractOrderTest {
}
Now MappingJackson2HttpMessageConverter is the only JSON converter left, and the serializer runs.
$ mvn -B test -Dtest=PreferredJackson2Test
...
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> Jackson 2 MoneySerializer called
...
Body = {"id":1,"price":"15000 KRW"}
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.573 s -- in com.example.order.PreferredJackson2Test
The same property works as a command-line argument to the jar. The response body matches the Spring Boot 3 test output.
$ java -jar target/order-boot4-0.0.1-SNAPSHOT.jar --server.port=18081 --spring.http.converters.preferred-json-mapper=jackson2
...
>>> Jackson 2 MoneySerializer called
$ curl -s http://localhost:18081/orders/1
{"id":1,"price":"15000 KRW"}
Jackson 2 mapper settings now live under spring.jackson2.*, not spring.jackson.*. The migration guide says these properties are equivalent to the spring.jackson properties in Spring Boot 3.5. Your existing spring.jackson.* settings now apply to the Jackson 3 mapper, so if you keep Jackson 2, rename the keys. For finer control, use a Jackson2ObjectMapperBuilderCustomizer bean.
Fix 2: port to Jackson 3 (Jackson 2 support is going away in 4.x)
The reference documentation says Jackson 2 support is deprecated and will be removed in a future Spring Boot 4.x release. The migration guide describes spring-boot-jackson2 as a stop-gap for users who need time to move to Jackson 3. Treat preferred-json-mapper=jackson2 as a way to buy time, not a permanent setting.
In the lab I ported the same serializer to Jackson 3. The imports and the method signature change.
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ser.std.StdSerializer;
// Jackson 3 serializer: writes Money as "15000 KRW"
public class MoneySerializer extends StdSerializer<Money> {
public MoneySerializer() {
super(Money.class);
}
@Override
public void serialize(Money value, JsonGenerator gen, SerializationContext context) {
System.out.println(">>> Jackson 3 MoneySerializer called");
gen.writeString(value.amount() + " " + value.currency());
}
}
- The package moves from
com.fasterxml.jacksontotools.jackson SerializerProvideris renamed toSerializationContext(databind #3043)- Exceptions become the unchecked
JacksonException, sothrows IOExceptiongoes away (databind #661)
The module extends tools.jackson.databind.module.SimpleModule, and the bean type becomes JacksonModule. Per the Spring Boot docs, JacksonModule beans are registered with the auto-configured JsonMapper.Builder.
// Spring Boot 4 registers JacksonModule beans with the auto-configured JsonMapper
@Bean
JacksonModule customModule() {
return new CustomModule();
}
I also added spring.jackson.use-jackson2-defaults=true to application.properties. According to the migration guide, it aligns the Jackson 3 mapper defaults as closely as possible with the Jackson 2 defaults from Spring Boot 3.x. It narrows the default-behavior differences that remain after you port the serializers.
spring.jackson.use-jackson2-defaults=true
With the default dependencies and no spring-boot-jackson2, the test passes.
$ mvn -B test
...
>>> converter: org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
>>> Jackson 3 MoneySerializer called
...
Body = {"id":1,"price":"15000 KRW"}
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
If you only have a few serializers, port them to Jackson 3 right away. If you depend on third-party libraries that only speak Jackson 2, or you have a lot of serialization code, upgrade with preferred-json-mapper=jackson2 first and port later.
Pitfalls: the test classpath and a converter bean
Staying on Jackson 2 with fix 1, I hit two traps in the lab.
Pitfall 1: excluding the Jackson 3 starter doesn’t remove Jackson 3 from tests
One Stack Overflow answer suggests removing spring-boot-starter-json and keeping only spring-boot-jackson2. In 4.1.2, spring-boot-starter-json still exists and depends on spring-boot-jackson, the Jackson 3 module. Even if you never declared it, spring-boot-starter-webmvc in 4.1.2 pulls in Jackson 3 through spring-boot-starter-jackson. So I excluded that starter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<exclusions>
<!-- remove Jackson 3 starter -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</exclusion>
</exclusions>
</dependency>
The executable jar now contains only the Jackson 2 libraries. Start it without the property, and Jackson 2 is selected automatically; the response is correct.
$ unzip -l target/order-boot4-jackson2-only-0.0.1-SNAPSHOT.jar | grep -i jackson
...
1709095 1980-02-01 00:00 BOOT-INF/lib/jackson-databind-2.21.7.jar
...
55766 1980-02-01 00:00 BOOT-INF/lib/spring-boot-jackson2-4.1.2.jar
$ curl -s http://localhost:18081/orders/1
{"id":1,"price":"15000 KRW"}
The MockMvc test in the same project still fails, though. The converter is back to JacksonJsonHttpMessageConverter.
$ mvn -B test -Dtest=OrderControllerTest
...
>>> converter: org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
...
java.lang.AssertionError: JSON path "$.price" expected:<15000 KRW> but was:<null>
The dependency tree explains why. spring-boot-starter-webmvc-test brings Jackson 3 back in test scope through spring-boot-starter-jackson-test.
$ mvn -B dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind,tools.jackson.core:jackson-databind
...
[INFO] +- org.springframework.boot:spring-boot-jackson2:jar:4.1.2:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-databind:jar:2.21.7:compile
[INFO] \- org.springframework.boot:spring-boot-starter-webmvc-test:jar:4.1.2:test
[INFO] \- org.springframework.boot:spring-boot-starter-jackson-test:jar:4.1.2:test
[INFO] \- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.2:test
[INFO] \- org.springframework.boot:spring-boot-jackson:jar:4.1.2:test
[INFO] \- tools.jackson.core:jackson-databind:jar:3.1.7:test

Production is right and only the tests break. If you "fix" the tests so they pass on Jackson 3, you end up testing a different mapper than the one you ship. In this project too, adding preferred-json-mapper=jackson2 made the test pass.
$ mvn -B test -Dtest=PreferredJackson2Test
...
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> Jackson 2 MoneySerializer called
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.473 s -- in com.example.order.PreferredJackson2Test
So even after excluding the Jackson 3 starter, keep the property in application.properties. Then the app and the tests use the same converter.
Pitfall 2: you don’t need a MappingJackson2HttpMessageConverter bean
I also tried what the Stack Overflow asker did: define a MappingJackson2HttpMessageConverter bean yourself. It takes the Jackson 2 ObjectMapper that Boot creates and wraps it in a converter.
@TestConfiguration
static class ConverterConfig {
@Bean
@SuppressWarnings("removal")
MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}
On 4.1.2 the bean alone, without the property, was enough to make the test pass. But the converter list shows the Jackson 3 converter still sitting behind it.
$ mvn -B test -Dtest=ConverterBeanTest
...
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> converter: org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
>>> Jackson 2 MoneySerializer called
Setting the property and the bean together gives the same result. With the property alone, the Jackson 2 converter was the only one; adding the bean puts the Jackson 3 converter back in the list.
$ mvn -B test -Dtest=ConverterBeanWithPropertyTest
...
>>> converter: org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
>>> converter: org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
>>> Jackson 2 MoneySerializer called
With two Jackson mappers in the converter list, it gets harder to tell which one produced a given response. On top of that, MappingJackson2HttpMessageConverter has been deprecated with forRemoval=true since Spring Framework 7.0. Its replacement is JacksonJsonHttpMessageConverter.
| Setup | MVC JSON converter | MoneySerializer |
|---|---|---|
spring-boot-jackson2 only |
JacksonJsonHttpMessageConverter |
not called |
+ preferred-json-mapper=jackson2 |
MappingJackson2HttpMessageConverter |
called |
| + converter bean | both Jackson 2 and Jackson 3 | called |
| + property + converter bean | both Jackson 2 and Jackson 3 | called |
| Jackson 3 starter excluded (jar) | Jackson 2 | called |
| Jackson 3 starter excluded (MockMvc test) | JacksonJsonHttpMessageConverter |
not called |
The simplest setup is to delete the converter bean you carried over from Spring Boot 3 and pick the mapper with the property. Boot already registers your Module beans with the ObjectMapper it builds, so there’s no reason to construct the converter yourself.
Summary
- On Spring Boot 4.1.2, adding
spring-boot-jackson2still leaves MVC responses on the Jackson 3 converter (JacksonJsonHttpMessageConverter). Jackson 2Modulebeans are ignored without any error - With Jackson 3 and Jackson 2 both present, select the Jackson 2 converter with
spring.http.converters.preferred-json-mapper=jackson2 - Excluding the Jackson 3 starter isn’t enough for tests:
spring-boot-starter-webmvc-testbrings Jackson 3 back in test scope. Keep the property so the app and tests match - You don’t need a
MappingJackson2HttpMessageConverterbean; adding one leaves the Jackson 3 converter in the list too, and the class is marked for removal since Spring Framework 7.0 - Jackson 2 support is slated for removal during 4.x, so the next step is moving to
tools.jacksonand usingspring.jackson.use-jackson2-defaults=trueto narrow default differences
References
- Spring Boot Reference: JSON (Jackson 3, Jackson 2)
- Spring Boot 4.0 Migration Guide: Upgrading Jackson, Jackson 2 Compatibility
- Spring Boot Common Application Properties
- Spring Boot How-to: Spring MVC (Customize the Jackson JsonMapper, HttpMessageConverters)
- Spring Framework Javadoc: MappingJackson2HttpMessageConverter
- Jackson Release 3.0 (FasterXML wiki)
- Stack Overflow: How do I migrate to Spring Boot 4 and maintain Jackson 2?