When writing REST APIs with Spring Boot, you will inevitably run into situations like “LocalDateTime comes back as an array,” “I want to drop null fields,” or “the frontend insists on snake_case.” This article covers which layer you should put your Jackson configuration in, the standard annotations, and how to customize the ObjectMapper. 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 an ObjectMapper as a Bean. There are roughly three places to configure it:
spring.jackson.*properties inapplication.yml(application-wide)- Annotations (per DTO class or per field)
- Bean definitions such as
Jackson2ObjectMapperBuilderCustomizer(programmatic extension)
The basic rule of thumb is to use YAML for behavior you want applied everywhere, and annotations when you only want to change specific fields.
Configuring Everything in application.yml
Let’s start with the commonly used YAML settings. These alone will cover about 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
With write-dates-as-timestamps: false in place, LocalDateTime is 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 realizing it.
Date and Time Formatting
The “LocalDateTime Comes Back as an Array” Problem
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]. The cause is that JavaTimeModule follows the default (true) of SerializationFeature.WRITE_DATES_AS_TIMESTAMPS and emits numeric output. Spring Boot 3.x auto-configuration flips this to false, but the problem resurfaces if you replace the ObjectMapper with your own Bean definition or build one from JsonMapper.builder() for tests. The safe approach is to explicitly set write-dates-as-timestamps: false in YAML, or call featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) in the Jackson2ObjectMapperBuilderCustomizer described earlier.
Java 8 date/time types are handled by the JavaTimeModule from jackson-datatype-jsr310. With Spring Boot 3.x it is registered automatically, 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 keeps the output offset stable. Conversely, LocalDateTime carries no time zone information, so the timezone attribute is ignored even if you set it. For fields where you want to preserve the time zone, use ZonedDateTime or OffsetDateTime.
Excluding null Fields
JsonInclude Mode Cheat Sheet
| Mode | What is excluded | Typical use case |
|---|---|---|
ALWAYS | Nothing (default) | Internal APIs that return every field |
NON_NULL | null only | General REST API responses |
NON_ABSENT | null and Optional.empty() | DTOs that make heavy use of Optional |
NON_EMPTY | null / empty strings / empty collections / empty Optional | When you don’t want to return empty arrays from list APIs |
NON_DEFAULT | The type’s default value (0, false, etc.) | Structs using primitives. Beware of values disappearing unintentionally |
There are many cases where you don’t want nulls in the response. Choose the granularity that fits.
@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
Long id,
String name,
@JsonInclude(JsonInclude.Include.NON_EMPTY)
List<String> roles,
String email
) {}
NON_NULLexcludes only nullNON_EMPTYalso excludes empty strings, empty collections, and empty OptionalsNON_DEFAULTalso excludes primitive default values (0andfalse), so handle it with care
If you want this applied globally, the default-property-inclusion: non_null setting shown earlier is sufficient.
When You Want snake_case
It is common for the frontend to expect snake_case such as created_at. Globally, property-naming-strategy: SNAKE_CASE does it in one shot; if you want a different name for only a specific field, override it with @JsonProperty.
public record ArticleResponse(
Long id,
String title,
@JsonProperty("author")
String authorName,
LocalDateTime publishedAt
) {}
The same rule applies on the deserialization side, so requests can be received 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 could also replace the entire ObjectMapper with a Bean definition, but this is not recommended because it rolls back Spring Boot’s default behavior. The moment you replace it, the spring.jackson.* auto-configuration is also disabled, which leads to the classic pitfall of “I set snake_case in YAML but it has no effect.” If you absolutely must replace it, explicitly apply the necessary settings on the ObjectMapper side, for example with 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 only add the delta on top of the ObjectMapper that Spring Boot has assembled.
Custom Serializer / Deserializer
Adding Annotations to Someone Else’s Classes with Mixins
For types where you can’t write annotations directly, such as third-party classes, Mixins come in handy. You can effectively attach annotations via addMixIn.
abstract class ExternalDtoMixin {
@JsonProperty("created_at")
abstract LocalDateTime getCreatedAt();
}
@Bean
public Jackson2ObjectMapperBuilderCustomizer mixinCustomizer() {
return builder -> builder.mixIn(ExternalDto.class, ExternalDtoMixin.class);
}
Varying the Response with @JsonView
If you want to serve the same DTO differently to an admin console and a public API, @JsonView lets you control output at field granularity using only an annotation on the controller method.
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
Input JSON key names sometimes vary due to external APIs or compatibility with older versions. With @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 results in a StackOverflowError. The standard fix is to put @JsonManagedReference on the parent side and @JsonBackReference on the child side, or to @JsonIgnore one side. A design that goes through a DTO layer is healthier in principle, but at the PoC stage these annotations let you work around it for the time being.
Handling Polymorphic Types with @JsonTypeInfo
To correctly serialize and deserialize fields of an abstract class or interface type, combine @JsonTypeInfo with @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 such as "type": "credit_card" is included in the JSON, so the correct concrete type is restored on deserialization as well.
For conversions that can’t be expressed with the standard tooling, 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
Fields you don’t want in the response, such as passwords or internal IDs, can be excluded with @JsonIgnore. For cases where you want to accept a value as input but never output it, @JsonProperty(access = ...) is convenient.
public record SignupRequest(
String email,
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
String password
) {}
With WRITE_ONLY, the field disappears from response output; with READ_ONLY, it is ignored when a request is received. This prevents accidents where confidential information slips into a response.
How to Handle Unknown Properties
When the request JSON contains a field the application doesn’t know about, an UnrecognizedPropertyException is thrown by default. For APIs that should be lenient toward client extensions, it is common to disable this.
Either set deserialization.fail-on-unknown-properties: false in YAML, or add @JsonIgnoreProperties(ignoreUnknown = true) on a per-class basis. Conversely, for internal APIs or endpoints where you want strict consistency, leaving it enabled lets you detect contract violations early.
If you want to standardize your error response format, see also Unifying Error Responses with Problem Details (RFC 9457) in Spring Boot. For a basic CRUD implementation, refer to Tutorial: Building a REST API with Spring Boot, and for DTO design, see Writing DTO Mappings with MapStruct.
Summary
The tried-and-true approach to shaping your JSON is to set the overall policy broadly in YAML, then make pinpoint adjustments with annotations on your DTOs. If that still isn’t enough, add modules via Jackson2ObjectMapperBuilderCustomizer or write a custom Serializer; there is almost never a need to jump straight to replacing the ObjectMapper Bean. When a setting doesn’t take effect, tracing “which layer is overriding it” step by step will usually lead you to the cause.