When Spring Boot developers build REST APIs, they inevitably run into situations like “LocalDateTime comes back as an array,” “I want to drop null fields,” or “the frontend expects snake_case.” This article summarizes where to configure Jackson, covering the standard annotations and custom ObjectMapper setup. It assumes Spring Boot 3.x / Java 17.

The three layers of Jackson configuration

When you add spring-boot-starter-web, Spring Boot auto-configures Jackson and registers ObjectMapper as a Bean. Broadly speaking, there are three places to configure it:

  • spring.jackson.* properties in application.yml (application-wide)
  • Annotations (per DTO class or per field)
  • Bean definitions such as Jackson2ObjectMapperBuilderCustomizer (programmatic extension)

The basic policy is to use YAML for behavior you want applied globally, and annotations when you want to change only specific fields.

Configuring everything at once in application.yml

For settings in application.yml where you don’t want to store sensitive information such as DB passwords or API keys in plain text, the ENC() encryption format introduced in How to Encrypt Sensitive Configuration Data with Jasypt in Spring Boot can help.

Let’s first look at the commonly used YAML settings. This alone covers 80% of real-world needs.

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Tokyo
    property-naming-strategy: SNAKE_CASE
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
    deserialization:
      fail-on-unknown-properties: false

Setting write-dates-as-timestamps: false makes LocalDateTime output as an ISO-8601 string instead of a numeric array like [2026,5,24,...]. This is a setting many people trip over at first without noticing.

Date and time formatting

The problem of LocalDateTime being returned as an array

If you forget to set spring.jackson.serialization.write-dates-as-timestamps, LocalDateTime is returned in the response as a numeric array like [2026,5,24,10,30,15]. This happens because JavaTimeModule follows the default (true) of SerializationFeature.WRITE_DATES_AS_TIMESTAMPS and outputs numbers. Spring Boot 3.x’s auto-configuration flips this to false, but the problem can resurface if you replace ObjectMapper with your own Bean definition, or if you build one from JsonMapper.builder() for testing. It’s safest to explicitly set write-dates-as-timestamps: false in YAML, or disable it via featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) in the Jackson2ObjectMapperBuilderCustomizer mentioned earlier.

Java 8 time types are handled by JavaTimeModule from jackson-datatype-jsr310. In Spring Boot 3.x this is auto-registered, so it works out of the box without adding a dependency.

To change the pattern on a per-field basis, use @JsonFormat.

public record OrderResponse(
    Long id,
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate orderDate,
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "Asia/Tokyo")
    ZonedDateTime createdAt
) {}

When using ZonedDateTime, also specifying the timezone attribute stabilizes the offset in the output. Conversely, since LocalDateTime has no timezone information, the timezone attribute is ignored even if set. For fields where you want to preserve the timezone, use ZonedDateTime or OffsetDateTime.

Excluding null fields

JsonInclude mode quick reference

ModeWhat’s excludedCommon use case
ALWAYSNothing excluded (default)Internal APIs that return all fields
NON_NULLnull onlyGeneral REST API responses
NON_ABSENTnull and Optional.empty()DTOs that make heavy use of Optional
NON_EMPTYnull/empty string/empty collection/empty OptionalWhen you don’t want empty arrays in list APIs
NON_DEFAULTType default values (0/false, etc.)Structures using primitives. Watch for unintended omissions

There are many cases where you don’t want nulls in the response. Choose the right granularity for the situation.

@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
    Long id,
    String name,
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    List<String> roles,
    String email
) {}
  • NON_NULL excludes only nulls
  • NON_EMPTY also excludes empty strings, empty collections, and empty Optionals
  • NON_DEFAULT also excludes primitive default values (0 or false), so handle with care

If you want to apply this globally, the default-property-inclusion: non_null shown earlier is sufficient.

Converting to snake_case

It’s common for frontends to expect snake_case like created_at. Globally, property-naming-strategy: SNAKE_CASE does the job in one shot; to rename only specific fields, override with @JsonProperty.

public record ArticleResponse(
    Long id,
    String title,
    @JsonProperty("author")
    String authorName,
    LocalDateTime publishedAt
) {}

The same rule applies on the deserialization side as well, so requests can also be received in snake_case.

Extending with Jackson2ObjectMapperBuilderCustomizer

For settings that can’t be expressed in YAML, or to register custom modules, use Jackson2ObjectMapperBuilderCustomizer. There’s also a way to replace the entire ObjectMapper with your own Bean definition, but this isn’t recommended since it rolls back Spring Boot’s default behavior. The moment you replace it, the spring.jackson.* auto-configuration is also disabled, leading to the classic pitfall of “I configured snake_case in YAML but it’s not working.” If you must replace it, explicitly set the necessary configuration on the ObjectMapper side, such as setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder -> builder
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .serializationInclusion(JsonInclude.Include.NON_NULL)
            .modulesToInstall(new MoneyModule());
    }
}

With this approach, you can add only the differences on top of the ObjectMapper that Spring Boot assembles.

Custom Serializers / Deserializers

Adding annotations to someone else’s class with Mixins

Mixins are useful for types where you can’t add annotations directly, such as third-party classes. addMixIn lets you attach annotations pseudo-virtually.

abstract class ExternalDtoMixin {
    @JsonProperty("created_at")
    abstract LocalDateTime getCreatedAt();
}

@Bean
public Jackson2ObjectMapperBuilderCustomizer mixinCustomizer() {
    return builder -> builder.mixIn(ExternalDto.class, ExternalDtoMixin.class);
}

Varying responses by audience with @JsonView

If you want to present the same DTO differently for an admin screen versus a public API, @JsonView lets you control field granularity using just annotations on controller methods.

public class Views {
    public interface Public {}
    public interface Admin extends Public {}
}

public record UserResponse(
    @JsonView(Views.Public.class) Long id,
    @JsonView(Views.Public.class) String name,
    @JsonView(Views.Admin.class) String email
) {}

@GetMapping("/users/{id}")
@JsonView(Views.Public.class)
public UserResponse get(...) { ... }

Accepting multiple key names with @JsonAlias

For compatibility with external APIs or older versions, input JSON key names may vary. By adding @JsonAlias, you can accept multiple input names while keeping the output fixed to a single name.

public record ImportRow(
    @JsonAlias({"user_id", "userID"}) Long userId,
    String name
) {}

Breaking circular references with @JsonManagedReference / @JsonBackReference

Serializing a bidirectional association on a JPA entity as-is causes a StackOverflowError. The standard fix is to put @JsonManagedReference on the parent side and @JsonBackReference on the child side, or apply @JsonIgnore to one side. A design that goes through a DTO layer is inherently healthier, but these annotations can serve as a stopgap during the PoC stage.

Handling polymorphic types with @JsonTypeInfo

Points to watch when writing a custom Serializer

When implementing your own JsonSerializer<T>, behavior depends on how you register it with Spring Boot’s auto-configured ObjectMapper. A field-level @JsonSerialize(using = ...) only affects that one field, whereas registering it in a SimpleModule and passing it via modulesToInstall affects every field that uses that type. Where either approach would work, it’s safer to start with @JsonSerialize, which has a narrower scope of impact. Testing can be done simply by calling objectMapper.writeValueAsString(...) directly and verifying the output string, so it’s reassuring to lock this down with unit tests before moving on to Controller integration tests.

Points to watch when writing a custom Deserializer

On the JsonDeserializer<T> side, explicitly defining behavior for unexpected input formats (such as null or empty strings) helps avoid pitfalls. If you pass the result of p.getText() along to subsequent processing without accounting for the null case, a NullPointerException can occur in a form that’s harder to trace than the original exception. This is often combined with the @JsonIgnoreProperties(ignoreUnknown = true) mentioned earlier, so designing it together with your policy on tolerating unknown properties keeps things consistent.

To correctly serialize and deserialize fields of an abstract class or interface type, combine @JsonTypeInfo and @JsonSubTypes.

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

Since the JSON side includes a discriminator key like "type": "credit_card", it is restored to the correct concrete type during deserialization.

For conversions that can’t be expressed by standard means, such as currency notation or custom Enums, implement JsonSerializer<T> / JsonDeserializer<T>. As an example, let’s write a serializer that outputs a BigDecimal in the ¥1,000 format.

public class YenSerializer extends JsonSerializer<BigDecimal> {
    private static final NumberFormat FORMAT =
        NumberFormat.getCurrencyInstance(Locale.JAPAN);

    @Override
    public void serialize(BigDecimal value, JsonGenerator gen,
                          SerializerProvider serializers) throws IOException {
        gen.writeString(FORMAT.format(value));
    }
}

To use it on a per-field basis, add @JsonSerialize(using = YenSerializer.class). To apply it application-wide, register it in a SimpleModule and pass it via modulesToInstall as shown earlier.

To convert an Enum back from its display label, implement JsonDeserializer<T>.

public class StatusDeserializer extends JsonDeserializer<Status> {
    @Override
    public Status deserialize(JsonParser p, DeserializationContext ctx)
            throws IOException {
        String label = p.getText();
        return Status.fromLabel(label);
    }
}

Fields you don’t want to output, and read-only fields

When to use @JsonIgnore vs. @JsonIgnoreProperties

@JsonIgnore is used at the field level when you want to “always exclude just this one field.” On the other hand, when you want to exclude multiple fields together at the class level, or specify names at the class level as in the @JsonIgnoreProperties({"password", "internalId"}) example used earlier for handling unknown properties, the latter is a better fit. In classes where getters/setters are auto-generated, such as with Lombok’s @Data, be careful: @JsonIgnore may need to be placed on the getter method rather than the field, or it won’t be honored.

For fields you don’t want to expose in the response, such as passwords or internal IDs, you can exclude them with @JsonIgnore. Additionally, for cases where you want to accept a field as input but not output it, @JsonProperty(access = ...) is handy.

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

With WRITE_ONLY, the field disappears when outputting a response; with READ_ONLY, it’s ignored when receiving a request. This prevents accidents where sensitive information accidentally leaks into a response.

How to handle unknown properties

When request JSON contains a field it doesn’t know about, Jackson throws UnrecognizedPropertyException by default. For APIs that want to be tolerant of client-side extensions, it’s common to disable this.

Either set deserialization.fail-on-unknown-properties: false in YAML, or add @JsonIgnoreProperties(ignoreUnknown = true) at the class level. Conversely, for internal APIs or endpoints where strict consistency matters, leaving it enabled lets you catch contract violations early. If you want to centrally handle this kind of invalid input outside the Controller, it’s worth reading Implementing Spring Boot’s GlobalExceptionHandler for Production Use, which covers patterns like trace ID attachment and ProblemDetail extension, since the implementation ties in with this.

If you want to standardize the format of error responses, also see Standardizing Error Responses with Problem Details (RFC 9457) in Spring Boot. For basic CRUD implementation, see A Tutorial on Building REST APIs with Spring Boot, and for DTO design, see Writing DTO Mappings with MapStruct.

Summary

The standard approach to shaping JSON is to roughly decide the overall policy in YAML, then make pinpoint adjustments on DTOs with annotations. If that’s still not enough, you can add modules via Jackson2ObjectMapperBuilderCustomizer or write a custom Serializer — there’s almost never a need to jump straight to replacing ObjectMapper with your own Bean. When a setting isn’t taking effect, tracing through “which layer is overriding this” in order will usually lead you to the cause.