Jackson in Spring Boot, and the Defaults Worth Changing

3 min read

  • Spring Boot
  • REST
  • Java

Jackson turns your objects into JSON without being asked, which is exactly why its behaviour is worth understanding. When a field appears in a response that should not be there, or a date arrives as 1756468800.000000000, that is Jackson following a default nobody chose.

What Spring Boot sets up

Spring Boot builds an ObjectMapper through Jackson2ObjectMapperBuilder with a few deviations from Jackson's own defaults: unknown properties do not fail deserialization, and JavaTimeModule is registered if it is on the classpath.

The right way to adjust it is a customizer, not a new ObjectMapper bean. Defining your own mapper bean replaces Boot's entirely and silently drops the modules it registered.

@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
    return builder -> builder
        .serializationInclusion(JsonInclude.Include.NON_NULL)
        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}

Most of it is also reachable from properties:

spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false

Dates

WRITE_DATES_AS_TIMESTAMPS is enabled by default in Jackson, which is why an Instant serialises as a decimal number of seconds. Turn it off and you get ISO-8601 strings, which every client can read. This is close to a mandatory change for a public API.

For a LocalDate or LocalDateTime, be explicit about the format rather than relying on the module's default:

public record Order(
    Long id,
    @JsonFormat(shape = STRING, pattern = "yyyy-MM-dd") LocalDate placedOn,
    Instant createdAt) {}

Prefer Instant for anything that is a moment in time and let the client localise. A LocalDateTime in an API is ambiguous by construction, because it carries no zone, and the bug it produces appears twice a year.

Unknown properties

Spring disables FAIL_ON_UNKNOWN_PROPERTIES, and that default is correct. It means a client can send a field you do not know about and the request still works, which is what lets you add fields to a request payload without breaking older callers.

Turning it on feels like strictness, and it converts every additive change made by a client into a 400. If you want to reject unexpected input, do it with validation on the fields you do care about.

Controlling what goes out

@JsonIgnore removes a field in both directions. @JsonProperty(access = WRITE_ONLY) accepts it on input and never writes it on output, which is what a password field wants. READ_ONLY is the reverse, for a server-assigned id.

public record UserRequest(
    String email,
    @JsonProperty(access = Access.WRITE_ONLY) String password) {}

The stronger version of this is to not have the field at all. Serialising an entity means every column, every association, and every future column you add becomes part of your public contract by accident. That is the practical argument for separate DTOs, and it is why @JsonIgnore scattered across an entity is a sign the boundary is in the wrong place.

Records and constructors

Since Jackson 2.12, records work without annotations: the canonical constructor is detected and the component names are used as property names. For ordinary classes with a single constructor, Spring Boot's mapper also infers parameter names, provided the class was compiled with -parameters, which Spring Boot's build plugin sets for you. If you build outside that setup and get Cannot construct instance ... no Creators, that flag is the usual cause.

Naming strategies are a property:

spring.jackson.property-naming-strategy=SNAKE_CASE

Decide this once, at the start. Changing it later is a breaking change for every client.

Polymorphism, carefully

@JsonTypeInfo with Id.CLASS embeds fully qualified class names in the JSON and, on the deserialization side, lets a caller name a class to instantiate. That has been the basis of a long line of remote code execution vulnerabilities. Use Id.NAME with an explicit @JsonSubTypes list, so only the types you nominated are reachable:

@JsonTypeInfo(use = Id.NAME, property = "type")
@JsonSubTypes({
    @Type(value = CardPayment.class, name = "card"),
    @Type(value = BankTransfer.class, name = "bank")
})
public sealed interface Payment permits CardPayment, BankTransfer {}

A sensible baseline

ISO dates, nulls omitted, unknown properties tolerated, DTOs rather than entities, one naming strategy chosen up front, and no class-name-based polymorphism. Set it once in a customizer and the rest of the codebase stops needing Jackson annotations at all.