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.

SyntaxString 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

MeaningJava typeJSON
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 offsetOffsetDateTime"2026-10-13T10:30:00+01:00"
A zone for display or schedulingZoneId"Europe/London"
A length of timeDuration"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.parse and call toInstant(), so clients may send Z or an offset. Reject strings with no offset at all; you cannot know which zone they meant.
  • Since Java 12, Instant.parse also accepts an offset such as +02:00 and converts to UTC.
  • Treat DateTimeParseException as 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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.123456789Z

Accepting 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.

Example · java
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:00Z

Dates 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.

Example · java
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

  • Be the first to comment on this lesson.