Dates in JSON APIs (ISO-8601)
Choose a clear JSON shape for dates, times and durations, produce ISO-8601 strings consistently, and accept client input safely.
String json = "{\"createdAt\":\"" + instant.truncatedTo(ChronoUnit.MILLIS) + "\"}";
Instant in = OffsetDateTime.parse(text).toInstant();
LocalDate due = LocalDate.parse("2026-10-13");JSON has no date type, so every API decides for itself how to write one. The choice that ages best is an ISO-8601 string, picked to match what the value means. ISO-8601 strings are readable in logs, parse in every language and, for a moment, carry their offset; UTC values in one fixed format also sort correctly as text, which mixed precision or mixed offsets break.
One shape per meaning
| Meaning | Java type | JSON |
|---|---|---|
| A moment (created, expires) | Instant | "2026-10-13T09:30:00Z" |
| A calendar date (birthday, due date) | LocalDate | "2026-10-13" |
| A local time with its offset | OffsetDateTime | "2026-10-13T10:30:00+01:00" |
| A zone for display or scheduling | ZoneId | "Europe/London" |
| A length of time | Duration | "PT15M" |
Do not send a date as a full timestamp at midnight UTC. "1990-03-12T00:00:00Z" shows up as 11 March in a browser west of Greenwich. A date is a date.
Producing consistent strings
toString() is ISO-8601, but its length varies. Instant.toString() prints 0, 3, 6 or 9 fraction digits depending on the value, and LocalDateTime.toString() leaves out :00 seconds entirely. Clients that compare strings, or parsers that expect a fixed format, break on the odd value. Truncate to the precision you promise (often milliseconds) or format with an explicit pattern, and document it.
Accepting client input
- Parse moments with
OffsetDateTime.parseand calltoInstant(), so clients may sendZor an offset. Reject strings with no offset at all; you cannot know which zone they meant. - Since Java 12,
Instant.parsealso accepts an offset such as+02:00and converts to UTC. - Treat
DateTimeParseExceptionas a 400 with the field name, not a 500. - Never return epoch numbers for some fields and strings for others in the same API. Pick one style and keep it.
With a JSON library
Jackson 3, which Spring Boot 4 uses (group tools.jackson.core), supports java.time with no extra module and writes it as ISO-8601 strings by default: DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS is off, so a LocalDate becomes "2026-10-13", an Instant "2026-10-13T10:00:00Z" and a Duration "PT5M". The older Jackson 2 line needs the jackson-datatype-jsr310 module registered and, unless SerializationFeature.WRITE_DATES_AS_TIMESTAMPS is turned off, writes dates as numbers. JSON-B also maps java.time types to ISO-8601 strings. Whichever library you use, check the actual JSON your endpoints produce in a test, because the defaults are where these differences hide.
Example
When to use it
- Return createdAt as a UTC ISO-8601 string with millisecond precision in every resource of a REST API.
- Accept event start times from a mobile app as ISO-8601 with an offset and normalise them to Instant before saving.
- Expose a delivery date as a plain yyyy-MM-dd string so web clients in any zone show the same day.
More examples
A fixed-width timestamp for every response
A formatter with withZone can format an Instant directly, and the explicit pattern always prints three fraction digits. X prints Z for a zero offset, so the output stays valid ISO-8601.
static final DateTimeFormatter API_TIME =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC);
void main() {
List<Instant> values = List.of(
Instant.parse("2026-10-13T09:30:00Z"),
Instant.parse("2026-10-13T09:30:00.5Z"),
Instant.parse("2026-10-13T09:30:00.123456789Z"));
for (Instant i : values) {
IO.println(API_TIME.format(i) + " toString: " + i);
}
}
// Output:
// 2026-10-13T09:30:00.000Z toString: 2026-10-13T09:30:00Z
// 2026-10-13T09:30:00.500Z toString: 2026-10-13T09:30:00.500Z
// 2026-10-13T09:30:00.123Z toString: 2026-10-13T09:30:00.123456789ZAccepting a timestamp from a client
Both the Z and the +02:00 forms describe the same moment and normalise to one Instant. A value with no offset is rejected instead of guessed, because the server cannot know which zone the client meant.
String readMoment(String field, String raw) {
try {
Instant at = OffsetDateTime.parse(raw).toInstant();
return "ok " + at;
} catch (DateTimeParseException e) {
return "400 " + field + " must be ISO-8601 with an offset, e.g. 2026-10-13T09:30:00Z";
}
}
void main() {
IO.println(readMoment("startsAt", "2026-10-13T09:30:00Z"));
IO.println(readMoment("startsAt", "2026-10-13T11:30:00+02:00"));
IO.println(readMoment("startsAt", "2026-10-13T09:30:00"));
IO.println(readMoment("startsAt", "13/10/2026 09:30"));
}
// Output:
// ok 2026-10-13T09:30:00Z
// ok 2026-10-13T09:30:00Z
// 400 startsAt must be ISO-8601 with an offset, e.g. 2026-10-13T09:30:00Z
// 400 startsAt must be ISO-8601 with an offset, e.g. 2026-10-13T09:30:00ZDates stay dates in JSON
Turning a calendar date into a moment invents a time and a zone, and any client west of UTC shows the day before. A plain yyyy-MM-dd string has no zone to get wrong.
void main() {
LocalDate birthday = LocalDate.of(1990, 3, 12);
// Wrong: a date sent as midnight UTC.
Instant asMoment = birthday.atStartOfDay(ZoneOffset.UTC).toInstant();
IO.println("sent: " + asMoment);
IO.println("seen in Chicago: " + asMoment.atZone(ZoneId.of("America/Chicago")).toLocalDate());
// Right: send the date itself.
String json = "{\"birthday\":\"" + birthday + "\"}";
IO.println("sent: " + json);
IO.println("read back: " + LocalDate.parse("1990-03-12"));
}
// Output:
// sent: 1990-03-12T00:00:00Z
// seen in Chicago: 1990-03-11
// sent: {"birthday":"1990-03-12"}
// read back: 1990-03-12
Discussion