Fading Coder

An Old Coder’s Final Dance

Home > Tech > Content

Fix LocalDateTime deserialization errors when converting JSON to objects in Spring Boot

Tech 2

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 LocalDateTimeDeserializer only handles ISO-8601 formats unless configured otherwise.
  • Front-end payloads often use human-friendly formats such as yyyy-MM-dd HH:mm:ss or yyyy-MM-dd HH:mm, and may also send just a date (yyyy-MM-dd) or even yyyy-MM. None of these match the strict ISO pattern with T.

What won’t fix body deserialization

  • @JsonFormat on each field works but is noisy, not global, and only supports a single pattern.
  • A Converter<String, LocalDateTime> or Formatter<LocalDateTime> only affects controller method parameters like @RequestParam or path variables, not @RequestBody JSON.

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:

  • LocalDateTime is timezone-agnostic; timezone settings (e.g., GMT+8) don’t change its parsed value.
  • If you prefer strict validation, replace the null returns 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.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.