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.
record Booking(LocalDate checkIn, Instant createdAt) {}
@JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/London") Instant shownAtJSON 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 type | JSON | Use 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
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
(pattern = "dd/MM/yyyy HH:mm", timezone = "Europe/London")
Instant createdAtForReception, // "13/10/2026 10:12": pattern needs a timezone
Duration holdFor) { // "PT15M"
}
("/api/bookings")
public class BookingController {
(HttpStatus.CREATED)
BookingResponse create( 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.
# 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=falseKeeping 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.
# 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=falsePinning 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.
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