Spring Boot 4: fixing Cannot map null into type int (Jackson 3)

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 missing int argument becomes null, and FAIL_ON_NULL_FOR_PRIMITIVES, on by default since 3.0, turns that into a 400.
Put @JsonCreator on 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.

Jackson 3 picks the public args constructor, the int argument missing from the JSON becomes null, and the request ends in 400

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 int argument missing from the JSON becomes null, and FAIL_ON_NULL_FOR_PRIMITIVES, on by default in Jackson 3, rejects it with Cannot map `null` into type `int` and a 400
  • If only a few DTOs are affected, @JsonCreator on the no-arg constructor has the smallest impact
  • For a global fix, use spring.jackson.deserialization.fail-on-null-for-primitives=false or spring.jackson.mapper.detect-parameter-names=false. They end up calling different constructors
  • Records have no no-arg constructor, so @JsonCreator doesn’t help. Disable FAIL_ON_NULL_FOR_PRIMITIVES if they must accept 0

References

Leave a Comment