LocalDateTime and ZonedDateTime

Tell a date-time with no zone from one tied to a region, and handle daylight saving gaps and overlaps instead of being surprised by them.

SyntaxLocalDateTime ldt = LocalDateTime.of(2026, 10, 13, 9, 30); ZonedDateTime zdt = ldt.atZone(ZoneId.of("Europe/London")); ZonedDateTime other = zdt.withZoneSameInstant(ZoneId.of("America/New_York"));

LocalDateTime is a date plus a wall-clock time, still with no zone: 2026-03-29T01:30. It is not a point in time. The same value happens hours apart in London and Sydney. ZonedDateTime adds a ZoneId such as Europe/London, and with it the zone's rules: its offset from UTC and when daylight saving starts and ends.

Which one to use

  • LocalDateTime for a time that people state without a place, and that should stay the same if they move: "remind me at 07:00", a recurring meeting at 10:00 local time, a scheduled publish time typed into a form before you know the zone.
  • ZonedDateTime when you need to know when something really happens in a region: the start of a webinar in New York, a store's closing time in a given city, the next run of a job that must fire at 02:00 in the customer's zone.
  • OffsetDateTime holds a fixed offset such as +01:00 with no rules. It is what most APIs and databases exchange, but it cannot tell you what the offset will be next month.

Use region IDs like Europe/London, never abbreviations like BST or EST, which are ambiguous and do not change with the seasons.

Daylight saving: gaps and overlaps

In London on 29 March 2026 the clocks jump from 01:00 to 02:00, so 01:30 never happens. Ask for it and ZonedDateTime.of moves it forward by the length of the gap, giving 02:30+01:00. On 25 October 2026 the clocks go back from 02:00 to 01:00, so 01:30 happens twice; Java picks the earlier offset by default, and withLaterOffsetAtOverlap() gives you the second one.

Arithmetic follows the same rules. plusDays(1) keeps the wall-clock time and may add 23 or 25 hours; plusHours(24) adds exactly 24 hours and may change the wall-clock time. Choose the one that matches what the user means.

Converting between zones

ZonedDateTime london = ZonedDateTime.of(meeting, ZoneId.of("Europe/London"));
ZonedDateTime tokyo = london.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

withZoneSameInstant keeps the moment and changes the clock reading, which is what you want for display. withZoneSameLocal keeps the clock reading and changes the moment; it is almost always a bug outside of fixing data that was stored in the wrong zone.

Back-end habits

Store moments in UTC (an Instant or a timestamptz column) and keep the user's ZoneId as a separate string. Convert to ZonedDateTime at the edges: when a user enters a local time, and when you show one. For a future event set in local time, such as a reminder at 07:00 next spring, also keep the LocalDateTime: governments change zone rules, and an Instant worked out today would then fire an hour off, so recompute it when the rules update. A LocalDateTime stored in a plain TIMESTAMP column with no zone recorded is a common source of data that nobody can interpret a year later.

Example

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

When to use it

  • Convert a webinar start time entered in the host's zone into each attendee's local time for confirmation emails.
  • Schedule a nightly report at 02:00 in each customer's region, accounting for the day the clocks change.
  • Turn a local date-time from a booking form plus the venue's ZoneId into a UTC Instant for the database.

More examples

Showing one meeting in several zones

withZoneSameInstant keeps the moment and changes only the clock reading, which is exactly what an invitation email needs. Note that the date changes in Sydney, so always show the date alongside the time.

Example · java
void main() {
    ZonedDateTime meeting = ZonedDateTime.of(
            LocalDateTime.of(2026, 10, 13, 16, 0), ZoneId.of("Europe/London"));

    for (String zone : List.of("Europe/London", "America/New_York", "Asia/Kolkata", "Australia/Sydney")) {
        ZonedDateTime local = meeting.withZoneSameInstant(ZoneId.of(zone));
        IO.println(zone + " -> " + local.toLocalDateTime() + " (" + local.getOffset() + ")");
    }
}
// Output:
// Europe/London -> 2026-10-13T16:00 (+01:00)
// America/New_York -> 2026-10-13T11:00 (-04:00)
// Asia/Kolkata -> 2026-10-13T20:30 (+05:30)
// Australia/Sydney -> 2026-10-14T02:00 (+11:00)

A user's local input to a UTC instant

The same 07:00 is a different UTC time in summer and winter, which only a region ID knows. Storing the Instant plus the zone ID lets you fire the reminder and show it back in the user's zone; for events far ahead, keep the typed local value too in case the zone rules change.

Example · java
record Reminder(Instant at, String zone) {}

Reminder schedule(String localInput, String userZone) {
    LocalDateTime wall = LocalDateTime.parse(localInput);
    ZonedDateTime zoned = wall.atZone(ZoneId.of(userZone));
    return new Reminder(zoned.toInstant(), userZone);
}

void main() {
    Reminder r1 = schedule("2026-07-01T07:00", "Europe/Paris");
    Reminder r2 = schedule("2026-12-01T07:00", "Europe/Paris");
    IO.println(r1);
    IO.println(r2);
    // Showing it back to the user uses the stored zone.
    IO.println(r1.at().atZone(ZoneId.of(r1.zone())).toLocalTime());
}
// Output:
// Reminder[at=2026-07-01T05:00:00Z, zone=Europe/Paris]
// Reminder[at=2026-12-01T06:00:00Z, zone=Europe/Paris]
// 07:00

Why a fixed offset is not a zone

New York is -04:00 in summer, so the hard-coded -05:00 puts the summer reminder an hour late. Save the region ID for anything recurring and let the zone rules pick the offset.

Example · java
void main() {
    LocalDateTime summer = LocalDateTime.of(2026, 7, 1, 9, 0);
    LocalDateTime winter = LocalDateTime.of(2026, 1, 15, 9, 0);

    ZoneId region = ZoneId.of("America/New_York");
    ZoneOffset fixed = ZoneOffset.ofHours(-5);

    IO.println("region summer: " + summer.atZone(region).toInstant());
    IO.println("region winter: " + winter.atZone(region).toInstant());
    IO.println("offset summer: " + summer.atOffset(fixed).toInstant());
    IO.println("offset winter: " + winter.atOffset(fixed).toInstant());
}
// Output:
// region summer: 2026-07-01T13:00:00Z
// region winter: 2026-01-15T14:00:00Z
// offset summer: 2026-07-01T14:00:00Z
// offset winter: 2026-01-15T14:00:00Z

Discussion

  • Be the first to comment on this lesson.