A common situation: the API contract renamed mail to email. The server DTO already has the new name, but a client still sends {"mail":"a@b.c"}, sometimes with an extra field nobody asked for. Spring Boot binds the body without complaint. email reaches the controller as null, and the response is 200. As far as HTTP is concerned, that 200 is correct. As far as the order is concerned, it isn't.
This isn't a Boot bug. It's the Jackson 3 default: unknown properties in the JSON are ignored. The trouble is that during a rename, the old name is an unknown property.
The plan is to send the same JSON through three setups using @WebMvcTest and MockMvcTester: the Boot default, the property spring.jackson.deserialization.fail-on-unknown-properties=true, and a DTO annotated with @JsonIgnoreProperties(ignoreUnknown = true). No container and no running server.
Versions, and the mapper that actually serves HTTP
The experiment ran on 4 October 2026 against Spring Boot 4.1.1, the newest GA in the 4.1 line. On Maven Central, the latest/release tag for spring-boot-starter-parent points to 4.2.0-M2, which is a milestone, so it isn't used here. The 4.1.1 BOM brings Spring Framework 7.0.9 and Jackson 3.1.5 (group tools.jackson), plus jackson-annotations 2.21. Runtime: Temurin 25.0.4 and Maven 3.9.12. The system requirements page says Boot 4.1.1 needs at least Java 17 and is compatible up to Java 26.
The mapper is what changed compared with Boot 2 and 3. The Boot 4.1 JSON documentation says "Jackson 3 is the preferred and default library" and that with Jackson on the classpath "a JsonMapper bean is automatically configured". That bean is tools.jackson.databind.json.JsonMapper, and Boot hands the same instance to JacksonJsonHttpMessageConverter, the converter that reads @RequestBody.
It is not com.fasterxml.jackson.databind.ObjectMapper, and it is not MappingJackson2HttpMessageConverter. Jackson 2 support still exists as spring-boot-jackson2, with spring.jackson2.* properties, but it's deprecated and not the default. Jackson 2 muscle memory will have typed ObjectMapper before you've finished reading the import, so check the package.
The fixture: one record, three endpoints
The POM uses the 4.1.1 parent, java.version 25, and spring-boot-starter-webmvc. In 4.1.1, starter-web is deprecated in favor of webmvc. The validation starter is only there for the @Valid case:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>jackson-unknown</artifactId>
<version>0.0.1</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The main DTO has one field, already using the new name:
package academy.devdojo.jacksonunknown;
public record OrderRequest(String email) {
}
The other two types are for comparison. One ignores unknown properties at the type level, and the other requires email:
package academy.devdojo.jacksonunknown;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record StrictIgnoreRequest(String email) {
}
package academy.devdojo.jacksonunknown;
import jakarta.validation.constraints.NotNull;
public record ValidOrderRequest(@NotNull String email) {
}
Look at the annotation import: com.fasterxml.jackson.annotation, not tools.jackson. Jackson 3 still keeps its annotations in the 2.x package.
The controller echoes back what it received, so the test can read the bind result straight from the response body:
@RestController
public class OrderController {
@PostMapping("/orders")
OrderRequest orders(@RequestBody OrderRequest request) {
return request;
}
@PostMapping("/orders/ignore")
StrictIgnoreRequest ignore(@RequestBody StrictIgnoreRequest request) {
return request;
}
@PostMapping("/orders/valid")
ValidOrderRequest valid(@Valid @RequestBody ValidOrderRequest request) {
return request;
}
}
There are four payloads:
- EXTRA:
{"email":"a@b.c","nickname":"ghost"} - RENAME:
{"mail":"a@b.c"} - EXTRA+RENAME:
{"mail":"a@b.c","nickname":"ghost"} - OMIT:
{}
The command was mvn -B -e test. Result: Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS.
Boot default: the extra field disappears and the old name becomes null
The DefaultJacksonUnknownTests class uses @WebMvcTest(OrderController.class) with no properties. The EXTRA case:
@Test
void extraFieldIsDroppedAndReturns200() {
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(EXTRA))
.hasStatusOk()
.bodyJson()
.extractingPath("$.email")
.isEqualTo("a@b.c");
System.out.println("DEFAULT EXTRA status=200 email=a@b.c extra dropped");
}
The nickname called ghost lives up to its name: it goes into the request and never shows up again. RENAME and EXTRA+RENAME use the same shape, but they assert .isNull() on $.email. The output:
DEFAULT EXTRA status=200 email=a@b.c extra dropped
DEFAULT RENAME status=200 email=null
DEFAULT EXTRA+RENAME status=200 email=null extra dropped
The test also injects the context's JsonMapper and compares it with a mapper built from the library alone:
@Test
void bootJsonMapperAndLibraryJsonMapperBothDisableFailOnUnknown() {
JsonMapper library = JsonMapper.builder().build();
boolean bootFail = bootMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
boolean libFail = library.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// ...
assertThat(bootFail).isFalse();
assertThat(libFail).isFalse();
OrderRequest fromLib = library.readValue(EXTRA, OrderRequest.class);
assertThat(fromLib.email()).isEqualTo("a@b.c");
}
BOOT fail-on-unknown=false
LIB fail-on-unknown=false
LIB EXTRA no throw email=a@b.c
LIB EXTRA with FAIL_ON_UNKNOWN_PROPERTIES throws UnrecognizedPropertyException
So Boot isn't the one adding the ignore. Jackson 3 does it by default. The 3.1.5 javadoc for DeserializationFeature says so plainly: "Feature is disabled by default as of Jackson 3.0 (in 2.x it was enabled)". A new JsonMapper() has the same default. What the Boot bean adds is its own customization: modules, mixins, and the spring.jackson.* properties. Building a "clean" mapper by hand doesn't make binding any stricter. You just lose that customization.
Turning on fail-on-unknown-properties
The Boot 4.1 Spring MVC how-to maps every value of the DeserializationFeature enum to spring.jackson.deserialization.<feature>, with relaxed binding. That makes fail-on-unknown-properties the same thing as FAIL_ON_UNKNOWN_PROPERTIES:
spring.jackson.deserialization.fail-on-unknown-properties=true
In the test, the property comes in through @TestPropertySource:
@WebMvcTest(OrderController.class)
@TestPropertySource(properties = "spring.jackson.deserialization.fail-on-unknown-properties=true")
class FailOnUnknownJacksonTests {
@Test
void extraFieldReturns400WithUnrecognizedProperty() {
MvcTestResult result = mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(EXTRA)
.exchange();
assertThat(result).hasStatus(HttpStatus.BAD_REQUEST);
Exception failure = result.getMvcResult().getResolvedException();
assertThat(failure).isInstanceOf(HttpMessageNotReadableException.class);
assertThat(failure.getCause()).isInstanceOf(UnrecognizedPropertyException.class);
}
// ...
}
The chain is what you'd expect. Jackson throws tools.jackson.databind.exc.UnrecognizedPropertyException, the converter wraps it in HttpMessageNotReadableException, and Spring MVC responds with 400. The message the test captured:
FAIL EXTRA status=400
FAIL EXTRA resolved=org.springframework.http.converter.HttpMessageNotReadableException
FAIL EXTRA message=JSON parse error: Unrecognized property "nickname" (class academy.devdojo.jacksonunknown.OrderRequest), not marked as ignorable
FAIL EXTRA cause=tools.jackson.databind.exc.UnrecognizedPropertyException
The rename is the interesting part. The leftover mail in the payload isn't a "missing field". To Jackson, it's a property that OrderRequest doesn't know about:
FAIL RENAME leftover old key status=400
FAIL RENAME message=JSON parse error: Unrecognized property "mail" (class academy.devdojo.jacksonunknown.OrderRequest), not marked as ignorable
{} still gets through, because there's nothing unknown in it. The email just never arrived:
FAIL OMIT {} status=200 email=null — missing is not unknown
Last, POST /orders/ignore with EXTRA, with the global property still on:
FAIL EXTRA on /orders/ignore status=200 annotation wins
@JsonIgnoreProperties(ignoreUnknown = true) on the type beat the global setting, and the 200 on that endpoint was measured, not assumed. That works as an escape hatch for one specific DTO, but it's also a hole someone can open without noticing. An annotation copied over from an old project switches off, for that one type, the protection you just turned on.
Unknown, missing, and the old name: the table
| Payload | Endpoint | Boot default | fail-on-unknown-properties=true |
|---|---|---|---|
| EXTRA | /orders | 200, email=a@b.c, nickname dropped | 400, HttpMessageNotReadableException caused by UnrecognizedPropertyException (nickname) |
| RENAME | /orders | 200, email=null | 400, Unrecognized property "mail" |
| EXTRA+RENAME | /orders | 200, email=null | not run |
OMIT {} | /orders | not run | 200, email=null |
| EXTRA | /orders/ignore | not run | 200, the type annotation wins |
| RENAME | /orders/valid | 400, MethodArgumentNotValidException | not run |
| EXTRA | /orders/valid | 200, email=a@b.c | not run |
The last two rows show the other half of the protection. With @Valid @RequestBody and @NotNull on the field, RENAME fails with 400 via MethodArgumentNotValidException: email arrives as null, "must not be null". EXTRA with email present passes with 200, because validation checks the record's fields, not whatever keys were left over in the JSON. The @RequestBody documentation describes that path: a validation error becomes MethodArgumentNotValidException and a 400.
Each mechanism covers one thing. fail-on-unknown-properties rejects keys the type doesn't know. @Valid with @NotNull rejects a required field that's missing. A rename with the old name still in the payload gets caught by both. An extra field next to the correct name only gets caught by the first.
The trap: treating the old name as "the field was just missing"
The most common misdiagnosis is to look at the null email and decide the client "forgot to send it". The client did send it, under the old name. If the team reads that as a missing field, the fix becomes a @NotNull, and the rename is only partly covered. If the team reads it as an unknown property, they turn on fail-on-unknown-properties, and mail becomes a 400 with the property name in the message. For a rename, the second reading tells you exactly what happened.
To watch for this in production:
- Turn the property on in a test or staging environment first, with real client traffic, and see which properties come up as
Unrecognized property. Clients that send extra fields "just in case" show up fast. - Log
HttpMessageNotReadableExceptionwith the route and the property name from the message. A jump in 400s withUnrecognized property "mail"right after a contract deploy points to an outdated client, not to instability. - Don't log the whole request body just to debug this. The property name is enough, and the payload may contain personal data.
Limits of the experiment
This was a functional run with MockMvcTester, no real server, no performance measurement, and a one-field DTO. Nested types, collections, polymorphic inheritance, and the Jackson 2 path (spring-boot-jackson2) weren't tested. Some combinations in the table weren't run and are marked as such.
The subject is request body deserialization by the Jackson 3 converter in Spring MVC. It sits close to two recent articles without overlapping them. RestClient 4xx on Boot 4.1 covers the client that throws on a 422 and says nothing about binding unknown properties on the way in. nested @Valid on ConfigurationProperties covers binding nested properties at startup, not HTTP JSON or JsonMapper.
Recommendation
DevDojo would not ship a public JSON API on Boot 4.1 that silently drops unknown properties, least of all during a field rename. In that situation, turn on spring.jackson.deserialization.fail-on-unknown-properties=true and put @Valid plus @NotNull on the required fields. Together they turn the old name into a 400 with a message that says which field is wrong.
The lenient default makes sense when you control the clients and they evolve before the server does, or in an internal API where extra fields are expected and harmless. Reconsider it if you see null in a business field alongside a 200, a contract rename with no deadline for clients to migrate, or @JsonIgnoreProperties(ignoreUnknown = true) spread across DTOs that should be strict.
Next step
Take the controller that receives your API's most important DTO and write a @WebMvcTest with @TestPropertySource(properties = "spring.jackson.deserialization.fail-on-unknown-properties=true"), along the lines of FailOnUnknownJacksonTests. Write two cases: one payload with an extra key, and one using the old name of a field that has already been renamed. Both should return 400 with UnrecognizedPropertyException as the cause. If either one comes back 200, look for a @JsonIgnoreProperties on that type before you change anything else.