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.
LocalDateTime 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
LocalDateTimefor 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.ZonedDateTimewhen 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.OffsetDateTimeholds a fixed offset such as+01:00with 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
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.
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.
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:00Why 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.
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