1. Overview
When working with external APIs or evolving systems, JSON data often changes over time, with new fields appearing in responses. If not handled properly, these differences can cause deserialization to fail and interrupt the data flow.
In this lesson, we’ll explore how to make applications more resilient to such cases by using the mechanisms Jackson provides for handling unknown properties.
The relevant module we need to import when starting this lesson is: handling-unknown-properties-start.
If we want to reference the fully implemented lesson, we can import: handling-unknown-properties-end.
2. Default Behavior and Strict Mode
Imagine that a brand is running multiple campaigns for its promotions and wants to also record the budget for each campaign for their reference, but our API hasn’t been updated to support a property for the budget data.
In Jackson 3.x, deserialization tolerates unknown properties by default. This means a JSON payload with extra fields will deserialize successfully, and Jackson will quietly discard the unrecognized fields. While this is convenient, it can also hide problems. If a field name changes upstream and we don’t notice, we may silently lose data.
To catch these situations early, we can enable strict mode by turning on the FAIL_ON_UNKNOWN_PROPERTIES feature. Let’s open our JacksonUnitTest class and add a test that demonstrates what happens when we enable this feature:
@Test
void givenUnknownProperty_whenUsingStrictMapper_thenFail() {
JsonMapper mapper = JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
String json = """
{
"code": "C2",
"name": "Campaign 2",
"description": "The description of Campaign 2",
"budget": 100
}
""";
assertThrows(UnrecognizedPropertyException.class, () -> mapper.readValue(json, Campaign.class));
}
Here, we build a JsonMapper using the builder pattern and explicitly enable FAIL_ON_UNKNOWN_PROPERTIES. Running this test confirms that with strict mode enabled, deserialization fails with an UnrecognizedPropertyException when unknown fields are present. This is useful when we want to enforce a strict contract between our application and the data source, catching unexpected schema changes immediately.
3. Configuring the JsonMapper Globally
Since Jackson 3.x tolerates unknown properties by default, the default JsonMapper already handles extra fields gracefully; no additional configuration is needed.
Let’s verify this by adding a test with a default JsonMapper that deserializes a JSON string containing the unknown budget property:
@Test
void givenDefaultMapper_thenDeserializationSucceeds() {
JsonMapper mapper = JsonMapper.builder().build();
String json = """
{
"code": "C2",
"name": "Campaign 2",
"description": "The description of Campaign 2",
"budget": 500
}
""";
Campaign campaign = mapper.readValue(json, Campaign.class);
assertEquals("C2", campaign.getCode());
}
Running this test confirms that the default mapper deserializes the JSON successfully, quietly dropping the unknown budget property.
If we need strict mode, we enable FAIL_ON_UNKNOWN_PROPERTIES on the builder, as we saw in Section 2. Keep in mind that this configuration applies to the entire JsonMapper instance. All deserialization done with this mapper will fail on unknown properties.
4. Class-Level Control with @JsonIgnoreProperties
Applying strict mode globally is not always a feasible solution. Consider our Task class. If we enable strict mode globally, then all deserialization through that mapper will fail on unknown properties, including Task objects, which we might want to handle differently.
This is where the @JsonIgnoreProperties annotation comes in handy. Adding @JsonIgnoreProperties(ignoreUnknown = true) to a class tells Jackson to tolerate unknown properties for that specific class, even when strict mode is enabled on the mapper.
Let’s create a new class called CampaignWithIgnoreUnknown that extends our Campaign class:
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignWithIgnoreUnknown extends Campaign {
}
Now, we’ll try to deserialize a JSON string with an unknown budget property using a strict JsonMapper:
@Test
void givenJsonIgnorePropertiesConfiguredToIgnoreUnknown_thenDeserializationSucceeds() {
JsonMapper mapper = JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
String json = """
{
"code": "C2",
"name": "Campaign 2",
"description": "The description of Campaign 2",
"budget": 500
}
""";
CampaignWithIgnoreUnknown campaign = mapper.readValue(json, CampaignWithIgnoreUnknown.class);
assertEquals("C2", campaign.getCode());
}
Even though the mapper has strict mode enabled, the @JsonIgnoreProperties annotation on the class overrides that setting, and the JSON is successfully deserialized. This approach allows us to enforce strict mode globally while selectively tolerating unknown properties for specific classes.
Furthermore, as we cover in another lesson, the @JsonIgnoreProperties annotation also allows us to list specific property names to ignore, whether during serialization or deserialization. This gives us fine-grained control to exclude only the fields we choose.
5. Capturing Unknown Fields with @JsonAnySetter
We’ve seen that we can discard the unknown properties, but sometimes discarding new properties is not enough. We may need to store them for auditing, logging, or round-tripping the original payload. The @JsonAnySetter annotation designates a method that receives every property Jackson cannot match, and those unknown properties can be saved in a Map for our future reference.
Let’s create a new class called CampaignWithSetUnknownProperties that extends the Campaign class and adds a Map attribute and a method to save the unknown properties:
public class CampaignWithSetUnknownProperties extends Campaign {
private Map<String, Object> unknownProperties = new HashMap<>();
@JsonAnySetter
void addUnknownProperties(String key, Object value) {
unknownProperties.put(key, value);
}
public Map<String, Object> getUnknownProperties() {
return unknownProperties;
}
}
Now, we’ll add a test method to see the same behavior in action:
@Test
void givenJsonAnySetterConfiguredToRecordUnknown_thenDeserializationSucceeds() {
JsonMapper mapper = JsonMapper.builder().build();
String json = """
{
"code": "C2",
"name": "Campaign 2",
"description": "The description of Campaign 2",
"budget": 500
}
""";
CampaignWithSetUnknownProperties campaign = mapper.readValue(json, CampaignWithSetUnknownProperties.class);
assertTrue(campaign.getUnknownProperties().containsKey("budget"));
}
Here, the budget field will be saved in the unknownProperties map.
Note: The setter method must accept two arguments: a String for the property name and a second parameter for the value (often Object, but Jackson will attempt type conversion if we use something more specific). Alternatively, we can annotate the Map
This strategy allows us to preserve every property of the original message, which is useful when forwarding data to another service, generating audit logs, or gradually migrating to a newer JSON schema.
6. Conclusion
In this lesson, we explored how Jackson handles unknown properties during deserialization. We started by noting that Jackson 3.x tolerates unknown properties by default, and then demonstrated how to enable strict mode with FAIL_ON_UNKNOWN_PROPERTIES to catch unexpected fields with an UnrecognizedPropertyException.
We then looked at three approaches for managing unknown properties: relying on the default JsonMapper behavior that silently drops unknown fields, using the @JsonIgnoreProperties(ignoreUnknown = true) annotation to override strict mode at the class level, and using @JsonAnySetter to capture unknown fields in a Map for later use. Each approach offers a different level of control depending on the application’s needs.