When writing REST APIs in Spring Boot, you inevitably run into situations like “LocalDateTime is returned as an array,” “I want to remove null fields,” or “the frontend requires snake_case.” In this article, I’ll summarize which layer to write Jackson configuration in, along with the standard annotations and ObjectMapper customization. This assumes Spring Boot 3.x / Java 17.

The Three Layers of Jackson Configuration

When you add spring-boot-starter-web to Spring Boot, Jackson is auto-configured and ObjectMapper is registered as a Bean. There are roughly three places to configure it.

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

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

Configuring Everything in application.yml

First, let’s look at commonly used settings in YAML. This alone handles 80% of real-world cases.

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

With write-dates-as-timestamps: false, LocalDateTime is output as an ISO-8601 string rather than a numeric array like [2026,5,24,...]. This is a setting many people get tripped up by initially because they don’t notice it.

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 responses as a numeric array like [2026,5,24,10,30,15]. The cause is that JavaTimeModule follows the default (true) of SerializationFeature.WRITE_DATES_AS_TIMESTAMPS and outputs as numbers. Spring Boot 3.x’s auto-configuration sets this to false, but it can recur if you replace ObjectMapper with your own Bean definition or build it from JsonMapper.builder() for testing. The safe approach is to set write-dates-as-timestamps: false in YAML, or explicitly use featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) via the aforementioned Jackson2ObjectMapperBuilderCustomizer.

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

To change the pattern per field, 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, specifying the timezone attribute as well stabilizes the output offset.

Excluding null Fields

JsonInclude Mode Quick Reference

ModeWhat’s ExcludedCommon Use Case
ALWAYSNothing (default)Internal APIs that return all fields
NON_NULLOnly nullGeneral REST API responses
NON_ABSENTnull and Optional.empty()DTOs that use Optional heavily
NON_EMPTYnull/empty strings/empty collections/empty OptionalWhen you don’t want empty arrays in list APIs
NON_DEFAULTType default values (0/false, etc.)Structures using primitives. Watch out for unintended omissions

There are many cases where you don’t want to include null in responses. Use them at different granularities.

@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 null
  • 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 earlier default-property-inclusion: non_null is sufficient.

When You Want snake_case

It’s common for frontends to expect snake_case like created_at. Globally, just set property-naming-strategy: SNAKE_CASE. If you want to use a different name for specific fields only, override with @JsonProperty.

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

The same rules apply on the deserialization side, so requests can be accepted in snake_case as well.

Extending with Jackson2ObjectMapperBuilderCustomizer

For settings that can’t be expressed in YAML or for registering custom modules, use Jackson2ObjectMapperBuilderCustomizer. You can also replace ObjectMapper entirely with a Bean definition, but this isn’t recommended because it reverts Spring Boot’s default behavior.

@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 to the ObjectMapper that Spring Boot has assembled.

Custom Serializer / Deserializer

Adding Annotations to Someone Else’s Class with Mixin

For types you can’t write annotations on directly, like third-party classes, Mixin is useful. You can pseudo-add annotations with addMixIn.

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

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

Differentiating Responses with @JsonView

When you want to return the same DTO differently for admin screens and public APIs, @JsonView lets you control fields at the granularity of just controller method annotations.

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 can vary. By adding @JsonAlias, you can accept multiple input names while fixing the output to a single name.

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

Breaking Circular References with @JsonManagedReference / @JsonBackReference

If you serialize bidirectional relationships in JPA entities as-is, you get StackOverflowError. The standard approach is to put @JsonManagedReference on the parent side, @JsonBackReference on the child side, or @JsonIgnore on one side. While designing through a DTO layer is healthier in principle, you can work around it temporarily with these annotations at the PoC stage.

Handling Polymorphic Types with @JsonTypeInfo

When you want to correctly serialize and deserialize fields of abstract class/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 {}

A discriminator key like "type": "credit_card" is included in the JSON, so deserialization correctly restores the concrete type.

For conversions that can’t be expressed by defaults, like currency notation or custom Enums, implement JsonSerializer<T> / JsonDeserializer<T>. As an example, let’s write a serializer that outputs BigDecimal in ¥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 per field, add @JsonSerialize(using = YenSerializer.class). To apply application-wide, register it with SimpleModule and pass it via the modulesToInstall shown earlier.

When you want to reverse-convert an Enum from a 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

Fields you don’t want in responses, like passwords or internal IDs, can be excluded with @JsonIgnore. Additionally, @JsonProperty(access = ...) is useful for cases where you want to receive a field as input but not output it.

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

WRITE_ONLY removes the field on response output, and READ_ONLY ignores it on request reception. This prevents accidents where confidential information accidentally leaks into responses.

How to Handle Unknown Properties

By default, when a request JSON contains fields you don’t recognize, an UnrecognizedPropertyException is thrown. For APIs where you want to be lenient with client extensions, it’s common to disable this.

Either set deserialization.fail-on-unknown-properties: false in YAML, or add @JsonIgnoreProperties(ignoreUnknown = true) per class. Conversely, leaving it enabled for internal APIs or endpoints where you want strict consistency lets you detect contract violations early.

To standardize your error response format, see Standardizing Error Responses with Spring Boot’s Problem Details (RFC 9457). For basic CRUD implementation, refer to Tutorial: Building a REST API with Spring Boot, and for DTO design, see Writing DTO Mapping with MapStruct.

Summary

When shaping JSON, the golden path is to roughly decide on the overall policy in YAML and then make pinpoint adjustments with annotations on your DTOs. If that’s still not enough, you can add modules with Jackson2ObjectMapperBuilderCustomizer or write a custom Serializer—there’s almost never a need to replace ObjectMapper with a Bean from the start. When settings don’t take effect, you can usually find the cause by tracing “which layer is overriding it” step by step.

Frequently Asked Questions (FAQ)

Q. I set snake_case in application.yml but it doesn’t work

If you completely replace ObjectMapper with a Bean, the auto-configuration of spring.jackson.* is disabled. Either customize via Jackson2ObjectMapperBuilderCustomizer, or explicitly call setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) on the replaced ObjectMapper.

Q. How do I fix LocalDateTime being returned as a [2026,5,24,...] array?

Either set spring.jackson.serialization.write-dates-as-timestamps: false, or disable SerializationFeature.WRITE_DATES_AS_TIMESTAMPS with featuresToDisable. It’s disabled by default in Spring Boot 3.x, but it recurs with custom ObjectMapper instances.

Q. What’s the difference between @JsonIgnore and @JsonProperty(access = WRITE_ONLY)?

@JsonIgnore is ignored for both reading and writing. WRITE_ONLY is read on request reception but removed on response output. It’s ideal for fields like passwords that you “receive but don’t return.”

Q. The timezone specification of @JsonFormat isn’t being applied

Since LocalDateTime doesn’t carry timezone information, the timezone attribute is ignored. If you want to retain the timezone, use ZonedDateTime or OffsetDateTime.