A @RequestBody endpoint that worked on Spring Boot 3 starts returning 400 after the upgrade to Spring Boot 4. Leave a single int field out of the JSON and the log shows JSON parse error: Cannot map `null` into type `int` . It happens when the DTO has both a no-arg constructor and an all-args constructor.
Jackson 3 picks the public args constructor when it’s the only one, even if a no-arg constructor exists.
The missingintargument becomesnull, andFAIL_ON_NULL_FOR_PRIMITIVES, on by default since 3.0, turns that into a 400.
Put@JsonCreatoron the no-arg constructor to get the Spring Boot 3 behavior back, or relax it globally with a property.
Environment
I ran the same DTO and controller on Spring Boot 4.1.1 and 3.5.16 and compared them with MockMvc tests.
| Item | Version |
|---|---|
| JDK | 21.0.12.1 (Temurin) |
| Maven | 3.10.0 |
| Spring Boot (repro and fixes) | 4.1.1 |
| Jackson (Boot 4.1.1) | tools.jackson.core:jackson-databind 3.1.5 |
| Spring Boot (comparison) | 3.5.16 |
| Jackson (Boot 3.5.16) | com.fasterxml.jackson.core:jackson-databind 2.21.4 |
Reproduce: JSON without age fails only on Spring Boot 4
The DTO is a plain setter-based class. The only twist is a second constructor that takes every field. Each constructor prints a line so you can see which one Jackson calls.
public class PersonDto {
private int id;
private String name;
private int age;
public PersonDto() {
System.out.println(">>> PersonDto() called");
}
public PersonDto(int id, String name, int age) {
System.out.println(">>> PersonDto(int, String, int) called");
this.id = id;
this.name = name;
this.age = age;
}
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}
The controller echoes the DTO back.
@PostMapping("/persons")
public PersonDto create(@RequestBody PersonDto personDto) {
return personDto;
}
The test sends JSON without age and expects 200 with age set to 0. That’s what Spring Boot 3 did: an int field with no value stayed at 0.
@Test
void missingAge() throws Exception {
mockMvc.perform(post("/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"id": 101, "name": "John Doe"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.age").value(0));
}
On Spring Boot 3.5.16 the test passes. The output shows that Jackson called the no-arg constructor.
$ mvn -B test
...
>>> PersonDto() called
>>> PersonDto() called
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.224 s -- in com.example.person.PersonControllerTest
Run the same code on Spring Boot 4.1.1 and the request fails with 400.
$ mvn -B test -Dtest=PersonControllerTest
...
WARN 10864 --- [ main] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)]
...
java.lang.AssertionError: Status expected:<200> but was:<400>
With Jackson 3.1.5 the message ends in (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow). The message in the Stack Overflow question differs only inside the parentheses, where it reads set DeserializationConfig.DeserializationFeature.... When you search, use just Cannot map `null` into type `int` .
A request with every field still succeeds on Spring Boot 4.1.1. Look at the output, though: Jackson called the three-argument constructor.
$ mvn -B test -Dtest=PersonControllerTest#fullJson
...
>>> PersonDto(int, String, int) called
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.183 s -- in com.example.person.PersonControllerTest
So Spring Boot 4 changed the constructor choice for every request, not just the broken ones. You don’t notice while clients send all fields. The first request that omits one exposes it.
Cause: two Jackson 3 changes stack up
Spring Boot 4 uses Jackson 3 by default. The dependency tree shows that 4.1.1 brings in tools.jackson.core:jackson-databind 3.1.5.
$ mvn -B dependency:tree -Dincludes=tools.jackson.core:jackson-databind
...
[INFO] \- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
[INFO] \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
[INFO] \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
The Jackson 3.0 release notes list two changes that lead straight to this error.
Change 1: an args constructor wins even with a no-arg constructor (databind #5318)
Jackson 3 folded the jackson-module-parameter-names functionality into databind (#1775). It reads constructor parameter names without an extra module. The names come from the bytecode, and they’re there because spring-boot-starter-parent configures maven-compiler-plugin with <parameters>true</parameters>.
#5318 builds on that. Even when a no-arg constructor exists, Jackson auto-detects the args constructor if both conditions hold:
- Only one args constructor is visible under the default visibility rules (public)
- Every parameter of that constructor has a name
PersonDto meets both. The release notes state that default constructor selection may differ from 2.x and that this is by design.
Spring Boot 3.5.16 ships the parameter-names module too. The dependency tree lists jackson-module-parameter-names 2.21.4. The difference is that Jackson 2 still preferred the no-arg constructor when one existed, so the problem never surfaced.
$ mvn -B dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind,com.fasterxml.jackson.module:jackson-module-parameter-names
...
[INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.21.4:compile
[INFO] \- com.fasterxml.jackson.module:jackson-module-parameter-names:jar:2.21.4:compile
Change 2: FAIL_ON_NULL_FOR_PRIMITIVES defaults to true (databind #4858)
When Jackson builds an object through an args constructor, any argument missing from the JSON is filled with null. In Jackson 2, FAIL_ON_NULL_FOR_PRIMITIVES defaulted to false, so that null quietly became int 0. Jackson 3.0 flipped the default to true. Now the null meant for age can’t be converted to int, and deserialization throws.

Change 1 makes Jackson pick the args constructor. Change 2 turns the missing primitive argument into an error. Either change alone wouldn’t produce this failure.
Fix: @JsonCreator or a Jackson setting
I ran all five fixes in the lab and recorded which constructor each one ended up calling. That detail matters, because two of the fixes leave the args constructor in charge.
Fix 1: put @JsonCreator on the no-arg constructor
This restores the Spring Boot 3 behavior for this one DTO. It’s one of the two workarounds the Jackson 3.0 release notes suggest.
@JsonCreator // fix: tell Jackson to use the no-arg constructor + setters
public CreatorPersonDto() {
System.out.println(">>> CreatorPersonDto() called");
}
@JsonCreator still lives in the com.fasterxml.jackson.annotation package, because Jackson 3 kept the annotations module on its old package name. With the annotation in place, Jackson calls the no-arg constructor and the test gets 200.
$ mvn -B test -Dtest=JsonCreatorFixTest
...
>>> CreatorPersonDto() called
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.179 s -- in com.example.person.JsonCreatorFixTest
The other workaround in the release notes is to make the args constructor non-public or mark it with @JsonIgnore.
Fix 2: spring.jackson.deserialization.fail-on-null-for-primitives=false
This is what the error message itself suggests. Spring Boot exposes each DeserializationFeature as a spring.jackson.deserialization.<feature_name> property. The lab sets it on the test. In a real app, put the same key in application.properties.
@SpringBootTest(properties = "spring.jackson.deserialization.fail-on-null-for-primitives=false")
This fix doesn’t change constructor selection. Jackson still calls the three-argument constructor and passes 0 for age.
$ mvn -B test -Dtest=FailOnNullPropertyFixTest
...
>>> PersonDto(int, String, int) called
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.197 s -- in com.example.person.FailOnNullPropertyFixTest
Fix 3: disable it with JsonMapperBuilderCustomizer
If your project keeps configuration in code, register a JsonMapperBuilderCustomizer bean. Note the new tools.jackson.databind package for DeserializationFeature.
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import tools.jackson.databind.DeserializationFeature;
@Bean
JsonMapperBuilderCustomizer allowNullForPrimitives() {
return builder -> builder
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);
}
The result matches fix 2. In the lab output, CustomizerFixTest printed >>> PersonDto(int, String, int) called and passed.
Fix 4: spring.jackson.mapper.detect-parameter-names=false
Jackson 3.0 added MapperFeature.DETECT_PARAMETER_NAMES to switch the parameter-names feature off (#5314). In Spring Boot, set spring.jackson.mapper.detect-parameter-names=false.
@SpringBootTest(properties = "spring.jackson.mapper.detect-parameter-names=false")
$ mvn -B test -Dtest=DetectParameterNamesTest
...
>>> PersonDto() called
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.152 s -- in com.example.person.DetectParameterNamesTest
Without parameter names, Jackson falls back to the no-arg constructor. The catch is that name detection is now off for the whole application. If you have immutable classes that rely on an args constructor without @JsonCreator, test them too.
Fix 5: spring.jackson.use-jackson2-defaults=true
Spring Boot 4 provides spring.jackson.use-jackson2-defaults to bring the defaults closer to what Jackson 2 used. With it set to true, the lab test called the no-arg constructor and passed.
@SpringBootTest(properties = "spring.jackson.use-jackson2-defaults=true")
$ mvn -B test -Dtest=Jackson2DefaultsTest
...
>>> PersonDto() called
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.200 s -- in com.example.person.Jackson2DefaultsTest
The Spring Boot docs describe this property as a migration aid. It changes several defaults at once, not just the one behind this error, so treat it as a stopgap.
Which fix to choose
| Fix | Constructor called | Scope | When to use it |
|---|---|---|---|
@JsonCreator (no-arg constructor) |
PersonDto() |
One class | Only a few DTOs break. Smallest blast radius |
fail-on-null-for-primitives=false |
Three-arg constructor | Global | Your API is fine receiving null as 0 |
JsonMapperBuilderCustomizer |
Three-arg constructor | Global | Same as above, configured in code |
detect-parameter-names=false |
PersonDto() |
Global | The project doesn’t rely on args-constructor auto-detection |
use-jackson2-defaults=true |
PersonDto() |
Global | Short-term patch right after the upgrade |
Finally, I ran all six fix tests (including the record test below) in one go, and they all passed.
$ mvn -B test -Dtest=JsonCreatorFixTest,FailOnNullPropertyFixTest,CustomizerFixTest,DetectParameterNamesTest,Jackson2DefaultsTest,RecordPropertyFixTest
...
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Pitfall: @JsonCreator can’t fix a Java record
A Java record has no no-arg constructor. Jackson has always built it through the canonical constructor. So the constructor selection change doesn’t matter here, and change 2 alone triggers the same error.
public record PersonRecord(int id, String name, int age) {
}
On Spring Boot 3.5.16, JSON without age passed with 200 and age 0. On Spring Boot 4.1.1, the same request returns 400.
$ mvn -B test -Dtest=RecordTest
...
Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)]
...
java.lang.AssertionError: Status expected:<200> but was:<400>
If a record must accept a missing field as 0, fail-on-null-for-primitives=false is the right fix. The record test passed with it in the lab. If you actually wanted to reject requests with missing values, the Jackson 3 default works in your favor. Keep the 400 and tell clients the field is required.
Think twice before turning the feature off globally. As #4858 explains, disabling it allows coercing a JSON null into primitives like int and long. That covers more than omitted fields: a request that sends "age": null also becomes 0 without any warning. If 0 is a meaningful value for the field, fix 1 is the safer choice.
Summary
- On Spring Boot 4.1.1 (Jackson 3.1.5), Jackson deserializes through the public args constructor when it’s the only one, even if a no-arg constructor exists
- An
intargument missing from the JSON becomesnull, andFAIL_ON_NULL_FOR_PRIMITIVES, on by default in Jackson 3, rejects it withCannot map `null` into type `int`and a 400 - If only a few DTOs are affected,
@JsonCreatoron the no-arg constructor has the smallest impact - For a global fix, use
spring.jackson.deserialization.fail-on-null-for-primitives=falseorspring.jackson.mapper.detect-parameter-names=false. They end up calling different constructors - Records have no no-arg constructor, so
@JsonCreatordoesn’t help. DisableFAIL_ON_NULL_FOR_PRIMITIVESif they must accept 0
References
- Jackson Release 3.0 (FasterXML wiki)
- jackson-databind #4858: Change default of FAIL_ON_NULL_FOR_PRIMITIVES to true for 3.0
- jackson-databind #5318: Allow auto-detection of Properties-based Constructor even if class has no-parameters Constructor
- jackson-databind #5314: Add MapperFeature.DETECT_PARAMETER_NAMES
- Spring Boot Reference: JSON (Jackson 3)
- Spring Boot How-to: Customize the Jackson JsonMapper
- Spring Boot Maven Plugin: Using the Plugin (Compilation with -parameters)
- Introducing Jackson 3 support in Spring (spring.io blog)
- Stack Overflow: Spring Boot 4 Jackson Deserialization behaves differently – Cannot map null into type int