Fix LocalDateTime deserialization errors when converting JSON to objects in Spring Boot
Symptom
When a request body contains date-time strings such as "2020-05-04 00:00" and the target fields are of type java.time.LocalDateTime, Spring Boot (via Jackson) fails to deserialize:
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` from String "2020-05-04 00:00": Failed to deserialize java.time.LocalDateTime: (java.time.format.DateTimeParseException) Text '2020-05-04 00:00' could not be parsed at index 10
The failure occurs at index 10 because the default JSR-310 LocalDateTimeDeserializer expects ISO-8601 (e.g., 2020-05-04T00:00:00) where the date and time are separated by T, not a space.
Why it happens
- Spring Boot uses Jackson with the Java Time module by default. The built-in
LocalDateTimeDeserializeronly handles ISO-8601 formats unless configured otherwise. - Front-end payloads often use human-friendly formats such as
yyyy-MM-dd HH:mm:ssoryyyy-MM-dd HH:mm, and may also send just a date (yyyy-MM-dd) or evenyyyy-MM. None of these match the strict ISO pattern withT.
What won’t fix body deserialization
@JsonFormaton each field works but is noisy, not global, and only supports a single pattern.- A
Converter<String, LocalDateTime>orFormatter<LocalDateTime>only affects controller method parameters like@RequestParamor path variables, not@RequestBodyJSON.
Example where a Converter/Formatter would apply but won’t help JSON bodies:
@GetMapping("/demo")
public void demo(@RequestParam("when") LocalDateTime when) {
// Works with Converter/Formatter, irrelevant for @RequestBody JSON
}
Goal
Keep LocalDateTime in the domain model and accept multiple non-ISO input formats from JSON without adding annotations on every field.
End-to-end solution: custom deserializer + global registration
Sample domain model
public class LeaveRequest {
private Integer id;
private Long applicant;
private LocalDateTime startAt;
private LocalDateTime endAt;
private String reason;
private String status;
private String rejectionNote;
private Long reviewer;
private LocalDateTime reviewedAt;
// getters/setters omitted
}
Controller
@RestController
public class LeaveRequestController {
private final LeaveRequestService service;
@Autowired
public LeaveRequestController(LeaveRequestService service) {
this.service = service;
}
@PostMapping("/leave-requests")
public void create(@RequestBody LeaveRequest request) {
service.create(request);
}
}
Custom LocalDateTime deserializer (accept multipel patterns)
The deserializer below accepts these inputs:
- yyyy-MM
- yyyy-MM-dd
- yyyy-MM-dd HH:mm
- yyyy-MM-dd HH:mm:ss
It also tolerates T by normalizing it to a space.
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
public final class FlexibleLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
private static final DateTimeFormatter DT_SECONDS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DT_MINUTES = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DateTimeFormatter D_ONLY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter YM_ONLY = DateTimeFormatter.ofPattern("yyyy-MM");
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String raw = p.getValueAsString();
if (raw == null) {
return null;
}
String text = raw.trim();
if (text.isEmpty()) {
return null; // treat empty as null; change to exception if you prefer strict mode
}
text = text.replace('T', ' ');
List<Exception> errors = new ArrayList<>(4);
try {
return LocalDateTime.parse(text, DT_SECONDS);
} catch (Exception e) { errors.add(e); }
try {
return LocalDateTime.parse(text, DT_MINUTES).withSecond(0);
} catch (Exception e) { errors.add(e); }
try {
LocalDate d = LocalDate.parse(text, D_ONLY);
return d.atStartOfDay();
} catch (Exception e) { errors.add(e); }
try {
YearMonth ym = YearMonth.parse(text, YM_ONLY);
return ym.atDay(1).atStartOfDay();
} catch (Exception e) { errors.add(e); }
throw InvalidFormatException.from(p, "Unsupported LocalDateTime value: " + text, text, LocalDateTime.class)
.withCause(errors.get(errors.size() - 1));
}
}
Global Jackson registration (recommended for Spring Boot)
Use a Jackson2ObjectMapperBuilderCustomizer so you don’t have to reorder HTTP message converters manually and the configuration applies application-wide.
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.format.DateTimeFormatter;
@Configuration
public class JacksonTimeConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer dateTimeCustomizer() {
return builder -> {
JavaTimeModule javaTime = new JavaTimeModule();
// Deserialization: accept multiple non-ISO formats
javaTime.addDeserializer(java.time.LocalDateTime.class, new FlexibleLocalDateTimeDeserializer());
// Serialization: choose a single, consistent output format
javaTime.addSerializer(java.time.LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.modules(javaTime);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
};
}
}
Alternative: manual message converter ordering (only if you must)
If you manage HttpMessageConverters yourself, ensure your MappingJackson2HttpMessageConverter with the customized ObjectMapper is first in the list. Avoid adding duplicate mappers unless you control the order.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
JavaTimeModule module = new JavaTimeModule();
module.addDeserializer(java.time.LocalDateTime.class, new FlexibleLocalDateTimeDeserializer());
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(module);
// Put the customized converter at position 0
converters.add(0, new MappingJackson2HttpMessageConverter(mapper));
}
}
Notes:
LocalDateTimeis timezone-agnostic;timezonesettings (e.g., GMT+8) don’t change its parsed value.- If you prefer strict validation, replace the
nullreturns for empty strings in the deserializer with an exception. - If field-level control is needed for specific properties,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")still works, but it’s not global.