Dates and Times in JSON

Choose the right java.time type, rely on ISO-8601 strings by default, and know how Jackson handles offsets, patterns and numeric timestamps.

Syntaxrecord Booking(LocalDate checkIn, Instant createdAt) {} @JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/London") Instant shownAt

JSON has no date type, so every API picks a convention. Spring Boot 4 with Jackson 3 picks a good one for you: java.time values are written as ISO-8601 strings, and the support is built into Jackson itself with no extra module.

Java typeJSONUse it for
Instant"2026-03-01T10:15:30Z"A moment: created, paid, expires
LocalDate"2026-03-01"A calendar date: birthday, check-in day
OffsetDateTime"2026-03-01T10:15:30+01:00"A moment where the sender's offset matters
Duration"PT1H30M"A length of time

Pick the type before the format

Most bugs come from the type, not the JSON. LocalDateTime has no zone, so "2026-03-01T10:15" is ambiguous the moment two servers or a user in another country are involved. Store and exchange moments as Instant in UTC and convert to a local time only for display. Use LocalDate when the value really is a date with no time, such as a hotel night, because converting it through a time zone can shift it by a day.

Offsets on input

By default Jackson adjusts an incoming OffsetDateTime to UTC: "10:15:30+01:00" arrives as 09:15:30Z. The instant is the same, but the original offset is lost. If you need it, for example to show a booking in the customer's own offset, disable adjust-dates-to-context-time-zone or store a ZoneId alongside an Instant.

Custom patterns

@JsonFormat(pattern = "dd/MM/yyyy") changes the format for one property. On an Instant a pattern only works with a timezone, because an instant has no calendar fields of its own; without it serialisation fails with an error such as Unsupported field: DayOfMonth (the field named depends on the pattern). Prefer ISO strings in APIs and format for humans in the client.

Invalid input

"2026-02-30" or "01/03/2026" sent to a LocalDate fails parsing, and Spring MVC answers 400 before your controller runs. Error handling later in the course shows how to turn that into a useful message for the client.

Numeric timestamps

Some clients want epoch numbers. Enabling write-dates-as-timestamps writes an Instant as seconds with a nanosecond fraction (1772360130.123000000), not milliseconds, and turns a LocalDate into an array [2026,3,1]. Also disable write-date-timestamps-as-nanoseconds if the client expects milliseconds.

Example

Example · java
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

// {"checkIn":"2026-03-01","checkOut":"2026-03-04","guestZone":"Europe/Paris"}
record BookingRequest(LocalDate checkIn, LocalDate checkOut, ZoneId guestZone) {}

record BookingResponse(
        long id,
        LocalDate checkIn,                 // "2026-03-01": a calendar date, no zone
        LocalDate checkOut,
        Instant createdAt,                 // "2026-10-13T09:12:44.512Z": a moment in UTC
        @JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/London")
        Instant createdAtForReception,     // "13/10/2026 10:12": pattern needs a timezone
        Duration holdFor) {                // "PT15M"
}

@RestController
@RequestMapping("/api/bookings")
public class BookingController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    BookingResponse create(@RequestBody BookingRequest request) {
        Instant now = Instant.now();
        return new BookingResponse(1, request.checkIn(), request.checkOut(), now, now, Duration.ofMinutes(15));
    }
}

When to use it

  • Store order creation times as Instant in UTC and show them in the customer's time zone only in the front end.
  • Accept hotel check-in and check-out dates that must never shift by a day when servers run in different zones.
  • Serve epoch milliseconds to an old dashboard while new clients keep ISO-8601 strings.

More examples

Epoch milliseconds for a legacy client

With both lines an <code>Instant</code> is written as <code>1772360130123</code>. This is global, so a <code>LocalDate</code> also changes, to <code>[2026,3,1]</code>. If only one client needs numbers, give it its own DTO with a <code>long</code> field rather than switching the whole application.

Example · properties
# Write dates as numbers instead of ISO strings.
spring.jackson.datatype.datetime.write-dates-as-timestamps=true
# Without this, Instant is written as seconds.nanoseconds (1772360130.123000000).
spring.jackson.datatype.datetime.write-date-timestamps-as-nanoseconds=false

Keeping the sender's offset

Both forms represent the same instant, so comparisons and storage as <code>Instant</code> are unaffected. Turn adjustment off only when the original offset carries meaning, such as showing a delivery slot in the customer's local time.

Example · properties
# By default an incoming OffsetDateTime is adjusted to UTC:
#   "2026-03-01T10:15:30+01:00" -> 2026-03-01T09:15:30Z
# Keep the offset the client sent instead:
spring.jackson.datatype.datetime.adjust-dates-to-context-time-zone=false

Pinning date formats in a JSON test

Date formats are a contract with every client, and a single property change can alter them all. A test like this fails the build if someone enables timestamps globally or adds a pattern that changes the wire format.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

@JsonTest
class StayJsonTest {

    record Stay(LocalDate checkIn, Instant createdAt) {}

    @Autowired
    JacksonTester<Stay> json;

    @Test
    void writesIsoStrings() throws Exception {
        var stay = new Stay(LocalDate.of(2026, 3, 1), Instant.parse("2026-03-01T10:15:30Z"));

        var written = json.write(stay);
        assertThat(written).extractingJsonPathStringValue("$.checkIn").isEqualTo("2026-03-01");
        assertThat(written).extractingJsonPathStringValue("$.createdAt").isEqualTo("2026-03-01T10:15:30Z");
    }

    @Test
    void readsIsoStrings() throws Exception {
        Stay stay = json.parseObject("{\"checkIn\":\"2026-03-01\",\"createdAt\":\"2026-03-01T10:15:30Z\"}");
        assertThat(stay.checkIn()).isEqualTo(LocalDate.of(2026, 3, 1));
    }
}

Discussion

  • Be the first to comment on this lesson.