JSON with Jackson: Customization and Pitfalls
Spring Boot converts objects to JSON and back with Jackson. Most API bugs about dates, null fields, unknown properties and infinite recursion are solved by knowing a handful of annotations and settings. Note: Spring Boot 3.x uses Jackson 2 (packages com.fasterxml.jackson.databind), and Spring Boot 4 uses Jackson 3 (packages tools.jackson.*, annotations stay in com.fasterxml.jackson.annotation). The ideas on this page are the same; imports and a few class names differ.
How it works
@RestController returns ProductResponse
▼
HttpMessageConverter (MappingJackson2HttpMessageConverter)
▼
ObjectMapper (a Spring bean configured by JacksonAutoConfiguration)
├─ modules auto-registered: JavaTimeModule (java.time), Jdk8Module (Optional), ParameterNamesModule (records/constructors)
├─ settings from spring.jackson.* properties
└─ Jackson2ObjectMapperBuilderCustomizer beans
▼
JSON text in the HTTP response
Request: JSON ─► ObjectMapper.readValue ─► your @RequestBody object (needs a constructor/record/setters)Global settings
spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false
spring.jackson.time-zone=UTC
spring.jackson.property-naming-strategy=SNAKE_CASEMost used annotations
import com.fasterxml.jackson.annotation.*;
@JsonInclude(JsonInclude.Include.NON_NULL) // skip null fields for this class
public class UserDto {
@JsonProperty("user_name") // different JSON name
private String name;
@JsonAlias({"mail", "e-mail"}) // accept several names when reading
private String email;
@JsonIgnore // never in JSON
private String passwordHash;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy")
private LocalDate birthDate;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
private Instant createdAt;
@JsonIgnoreProperties(ignoreUnknown = true) // (class level in real code)
private Map<String, Object> extra;
// getters and setters
}| Annotation | Use |
|---|---|
| @JsonProperty | Rename a field or force it to be included |
| @JsonIgnore / @JsonIgnoreProperties | Hide fields (passwords, internal ids) |
| @JsonInclude(NON_NULL / NON_EMPTY) | Skip null or empty values |
| @JsonAlias | Accept more names on input |
| @JsonFormat | Date and enum formats |
| @JsonCreator + @JsonProperty | Choose the constructor or factory used to create the object |
| @JsonTypeInfo + @JsonSubTypes | Polymorphic types (Dog and Cat behind Animal) |
| @JsonView | Different field sets for different API views |
Records and immutable objects
public record CreateOrderRequest(String customerId, List<OrderLine> lines) {
public record OrderLine(String sku, int qty) { }
}
// Jackson 2.12+ supports records. No annotations needed when the parameter names are available.Custom serializer and deserializer
// Money is written as "12.50 INR" and read back
public class MoneySerializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(value.amount().setScale(2, RoundingMode.HALF_UP) + " " + value.currency());
}
}
public class MoneyDeserializer extends JsonDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String[] parts = p.getValueAsString().split(" ");
return new Money(new BigDecimal(parts[0]), parts[1]);
}
}
@JsonComponent // Spring Boot 3.x. In Boot 4 this is @JacksonComponent
public class MoneyJsonComponent {
public static class Ser extends MoneySerializer { }
public static class Deser extends MoneyDeserializer { }
}
// Alternative: annotate the class directly
// @JsonSerialize(using = MoneySerializer.class) @JsonDeserialize(using = MoneyDeserializer.class)
public record Money(BigDecimal amount, String currency) { }Customize the shared ObjectMapper (Boot 3.x)
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToEnable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.serializationInclusion(JsonInclude.Include.NON_NULL);
}
}Polymorphism
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CardPayment.class, name = "card"),
@JsonSubTypes.Type(value = UpiPayment.class, name = "upi")
})
public interface Payment { }
public record CardPayment(String cardNumber) implements Payment { }
public record UpiPayment(String vpa) implements Payment { }
// {"type":"upi","vpa":"ram@bank"} becomes a UpiPaymentTypical problems
| Problem | Cause | Fix |
|---|---|---|
| Infinite recursion / StackOverflowError | Bidirectional JPA relations (Customer has orders, order has customer) | Return DTOs. Otherwise @JsonManagedReference / @JsonBackReference or @JsonIgnore on one side |
| Dates shown as numbers | WRITE_DATES_AS_TIMESTAMPS is on | Set spring.jackson.serialization.write-dates-as-timestamps=false |
| 400 Unrecognized field | Client sends an unknown property and fail-on-unknown is true | Fix the client, or set fail-on-unknown-properties=false (understand the risk) |
| LazyInitializationException while writing JSON | Serializer touches a lazy field after the session closed | Map to a DTO inside the service |
| Big decimals become floating numbers in JavaScript | JS numbers lose precision | Send money as string, or use minor units (paise) |
Use ObjectMapper directly
@Service
public class JsonService {
private final ObjectMapper mapper; // the Spring-configured instance
public JsonService(ObjectMapper mapper) { this.mapper = mapper; }
public String toJson(Object o) throws JsonProcessingException { return mapper.writeValueAsString(o); }
public <T> T fromJson(String json, Class<T> type) throws JsonProcessingException { return mapper.readValue(json, type); }
public List<UserDto> fromJsonList(String json) throws JsonProcessingException {
return mapper.readValue(json, new TypeReference<List<UserDto>>() { });
}
}Common mistake: Creating a new ObjectMapper with new ObjectMapper() in your code. It ignores all Spring Boot settings (date format, modules, naming). Inject the Spring-managed ObjectMapper.
Interview questions
How do I hide a field from the JSON response?
Use @JsonIgnore on the field or, better, do not include it in the response DTO.
Why did Jackson fail with 'no Creators, like default constructor'?
It cannot create your class. Add a default constructor and setters, use a record, or annotate a constructor with @JsonCreator.
What changes in Spring Boot 4?
Jackson 3 becomes the default: packages move to tools.jackson, some class names change (for example @JacksonComponent), and exceptions are unchecked. Annotations from com.fasterxml.jackson.annotation stay the same.