Convert LocalDateTime to ZonedDateTime in Java (and Back)

Convert LocalDateTime to ZonedDateTime in Java with atZone(), ofLocal() and ofStrict(), see how DST gaps and overlaps resolve, and convert back safely.

Results of atZone, ofLocal and ofStrict for a normal time, a time in the March 29 gap and a time in the October 25 overlap in Europe/Madrid

Calling atZone(zoneId) on a LocalDateTime is how we convert a LocalDateTime to a ZonedDateTime in Java, and the result keeps the date and time as they are and adds the zone together with the offset that zone uses on that date. The way back is toLocalDateTime(), which drops the zone and the offset.

Every app that takes a date and time from a form, or from a column without a zone, needs this step before the value is a real moment that it can use, for example to store it in UTC or to show it in another country.

The following example turns a concert start time in Madrid into a ZonedDateTime and back, and shows the start time to a fan in Chicago.

LocalDateTime showStart = LocalDateTime.of(2026, 10, 3, 19, 30);
ZonedDateTime atVenue = showStart.atZone(ZoneId.of("Europe/Madrid"));                 // 2026-10-03T19:30+02:00[Europe/Madrid]
ZonedDateTime viaOf = ZonedDateTime.of(showStart, ZoneId.of("Europe/Madrid"));        // 2026-10-03T19:30+02:00[Europe/Madrid]
ZonedDateTime forFan = atVenue.withZoneSameInstant(ZoneId.of("America/Chicago"));     // 2026-10-03T12:30-05:00[America/Chicago]
LocalDateTime back = atVenue.toLocalDateTime();                                       // 2026-10-03T19:30

We can see that 19:30 stays 19:30 and only gains the zone, while the fan’s time is a real conversion. The sections that follow cover why the zone is needed, the four factory methods and how they treat daylight saving time, values stored in UTC, the way back, and a ticket sale example that validates user input.

1. Why a LocalDateTime Needs a Zone

A LocalDateTime is a date and a time on a wall clock, such as 2026-10-03T19:30, with no information about the place. The same value is a different moment in Madrid and in Chicago, so it cannot be compared with timestamps, stored as UTC or converted to another zone until we add a zone.

A ZonedDateTime adds a zone ID and the offset for that date. We pass the zone, and the zone rules pick the offset, so the same call gives +01:00 in winter and +02:00 in summer for Madrid.

ZoneId madrid = ZoneId.of("Europe/Madrid");
ZonedDateTime winterShow = LocalDateTime.of(2026, 1, 17, 19, 30).atZone(madrid);   // 2026-01-17T19:30+01:00[Europe/Madrid]
ZonedDateTime summerShow = LocalDateTime.of(2026, 7, 17, 19, 30).atZone(madrid);   // 2026-07-17T19:30+02:00[Europe/Madrid]
ZonedDateTime fixedShow = LocalDateTime.of(2026, 7, 17, 19, 30).atZone(ZoneOffset.ofHours(1));   // 2026-07-17T19:30+01:00

The last line passes a fixed offset, which never changes with the season. That is fine for systems that define their times in an offset, but for people and places we pass a region ID such as Europe/Madrid.

2. Four Ways to Attach a Zone

Java has four methods that build a ZonedDateTime from a LocalDateTime. They give the same result for an ordinary time and differ only when the local time falls into a daylight saving gap or overlap, which the next section covers.

MethodWhat it doesIn a gap or overlap
ldt.atZone(zone)Attaches the zoneGap moves forward, overlap takes the earlier offset
ZonedDateTime.of(ldt, zone)Same as atZone()Same as atZone()
ZonedDateTime.ofLocal(ldt, zone, preferredOffset)Attaches the zone, prefers the given offsetOverlap takes the preferred offset if it is valid
ZonedDateTime.ofStrict(ldt, offset, zone)Checks that the offset is valid for the zoneThrows DateTimeException for a gap or a wrong offset

The method atZone() is the one we use in most code. The method ofLocal() helps when we already know the offset, for example from a previous value, and ofStrict() helps when a wrong combination must fail.

LocalDateTime doorsOpen = LocalDateTime.of(2026, 10, 3, 18, 0);
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZonedDateTime viaAtZone = doorsOpen.atZone(madrid);                                       // 2026-10-03T18:00+02:00[Europe/Madrid]
ZonedDateTime viaOfLocal = ZonedDateTime.ofLocal(doorsOpen, madrid, ZoneOffset.ofHours(2));   // 2026-10-03T18:00+02:00[Europe/Madrid]
ZonedDateTime viaOfStrict = ZonedDateTime.ofStrict(doorsOpen, ZoneOffset.ofHours(2), madrid);   // 2026-10-03T18:00+02:00[Europe/Madrid]
ZonedDateTime wrongOffset = ZonedDateTime.ofStrict(doorsOpen, ZoneOffset.ofHours(1), madrid);   // DateTimeException: ZoneOffset '+01:00' is not valid for LocalDateTime '2026-10-03T18:00' in zone 'Europe/Madrid'
OffsetDateTime offsetOnly = doorsOpen.atOffset(ZoneOffset.ofHours(2));                    // 2026-10-03T18:00+02:00

The last line is the close relative atOffset(), which returns an OffsetDateTime without zone rules. The trade-offs between the two result types are in ZonedDateTime vs OffsetDateTime.

3. Local Times That Do Not Exist or Happen Twice

Twice a year, a zone with daylight saving time breaks the one-to-one link between local time and instant. In Madrid on March 29, 2026, clocks jump from 02:00 to 03:00, so 02:30 does not exist. On October 25, 2026, clocks go back from 03:00 to 02:00, so 02:30 happens twice, once at +02:00 and once more at +01:00.

Results of atZone, ofLocal and ofStrict for a normal time, a time in the March 29 gap and a time in the October 25 overlap in Europe/Madrid
For an ordinary time all methods agree; they differ only in the daylight saving gap and overlap

The method atZone() never throws for a gap or an overlap, so the stored time can differ from the time a user typed. A time in the gap moves forward by the length of the gap, and a time in the overlap gets the earlier offset.

ZoneId madrid = ZoneId.of("Europe/Madrid");
LocalDateTime inGap = LocalDateTime.of(2026, 3, 29, 2, 30);
LocalDateTime inOverlap = LocalDateTime.of(2026, 10, 25, 2, 30);
ZonedDateTime gapResult = inGap.atZone(madrid);                                              // 2026-03-29T03:30+02:00[Europe/Madrid]
ZonedDateTime overlapEarlier = inOverlap.atZone(madrid);                                     // 2026-10-25T02:30+02:00[Europe/Madrid]
ZonedDateTime overlapLater = ZonedDateTime.ofLocal(inOverlap, madrid, ZoneOffset.ofHours(1));   // 2026-10-25T02:30+01:00[Europe/Madrid]
ZonedDateTime strictGap = ZonedDateTime.ofStrict(inGap, ZoneOffset.ofHours(1), madrid);      // DateTimeException: LocalDateTime '2026-03-29T02:30' does not exist in zone 'Europe/Madrid' due to a gap in the local time-line, typically caused by daylight savings

To find out which case we have before converting, we ask the zone rules for the valid offsets of the local time. An empty list means a gap, two offsets mean an overlap, and one offset is the normal case.

ZoneRules rules = ZoneId.of("Europe/Madrid").getRules();
int gapOffsets = rules.getValidOffsets(LocalDateTime.of(2026, 3, 29, 2, 30)).size();       // 0
int overlapOffsets = rules.getValidOffsets(LocalDateTime.of(2026, 10, 25, 2, 30)).size();  // 2
int normalOffsets = rules.getValidOffsets(LocalDateTime.of(2026, 10, 3, 19, 30)).size();   // 1

4. LocalDateTime Values Recorded in UTC

Many apps keep timestamps in a TIMESTAMP column without a zone and agree that the values are UTC. Such a value is read back as a LocalDateTime, and atZone() with the user’s zone would label UTC as local time, which is wrong by the zone’s offset. We attach UTC first and convert after that.

LocalDateTime soldAtUtc = LocalDateTime.of(2026, 10, 3, 17, 30);                          // from a UTC column
ZonedDateTime soldInMadrid = soldAtUtc.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of("Europe/Madrid"));   // 2026-10-03T19:30+02:00[Europe/Madrid]
ZonedDateTime mislabeled = soldAtUtc.atZone(ZoneId.of("Europe/Madrid"));                   // 2026-10-03T17:30+02:00[Europe/Madrid]

The general case of moving a value from one zone to another is covered in converting date and time between time zones.

5. ZonedDateTime to LocalDateTime

The method toLocalDateTime() returns the date and time as they read in the value’s own zone and drops the zone and the offset. If we need the local time of another zone, we convert with withZoneSameInstant() first.

ZonedDateTime show = ZonedDateTime.of(2026, 10, 3, 19, 30, 0, 0, ZoneId.of("Europe/Madrid"));
LocalDateTime venueTime = show.toLocalDateTime();                                                  // 2026-10-03T19:30
LocalDateTime chicagoTime = show.withZoneSameInstant(ZoneId.of("America/Chicago")).toLocalDateTime();   // 2026-10-03T12:30
LocalDateTime utcTime = LocalDateTime.ofInstant(show.toInstant(), ZoneOffset.UTC);                  // 2026-10-03T17:30

The result has no zone, so we keep the zone somewhere else, such as in the venue record, if we ever need to turn the value back into a moment. For dates without a time, the matching pair of methods is described in LocalDate to ZonedDateTime.

6. Validating the Start of a Ticket Sale

A ticketing app lets an organizer pick the date and time when the sale opens, in the venue’s zone. If an organizer in Madrid picks 02:30 on March 29, atZone() would save 03:30 without an error, and fans would see a different time than the organizer typed. The app rejects a time in the gap and asks the organizer which 02:30 is meant in the overlap.

static ZonedDateTime saleStart(LocalDateTime picked, ZoneId venueZone, boolean secondOccurrence) {
    if (venueZone.getRules().getValidOffsets(picked).isEmpty()) {
        throw new IllegalArgumentException(picked + " does not exist in " + venueZone);
    }
    ZonedDateTime first = picked.atZone(venueZone);
    return secondOccurrence ? first.withLaterOffsetAtOverlap() : first;
}
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZonedDateTime normal = saleStart(LocalDateTime.of(2026, 10, 3, 10, 0), madrid, false);    // 2026-10-03T10:00+02:00[Europe/Madrid]
ZonedDateTime second = saleStart(LocalDateTime.of(2026, 10, 25, 2, 30), madrid, true);    // 2026-10-25T02:30+01:00[Europe/Madrid]
ZonedDateTime rejected = saleStart(LocalDateTime.of(2026, 3, 29, 2, 30), madrid, false);  // IllegalArgumentException: 2026-03-29T02:30 does not exist in Europe/Madrid

The method throws IllegalArgumentException with a clear message, which a REST controller can turn into a 400 response. For the overlap, withLaterOffsetAtOverlap() switches to the winter 02:30, and for any other time it returns the value unchanged, so the flag is safe to pass for every input.

7. LocalDateTime to ZonedDateTime FAQs

Form input and database values lead to questions about the two factory methods, changed times, UTC and a missing zone.

7.1. Is There Any Difference Between atZone() and ZonedDateTime.of()?

No. LocalDateTime.atZone(zone) calls ZonedDateTime.of(this, zone), so both handle gaps and overlaps the same way. We pick the one that reads better in the code.

7.2. Does atZone() Change the Date or Time?

Only in a daylight saving gap. For every other value, atZone() keeps the date and the time and adds the zone and the offset. To get the same moment in another zone, we call withZoneSameInstant() on the result.

7.3. How Do We Convert a LocalDateTime to a ZonedDateTime in UTC?

If the value is already UTC, we call atZone(ZoneOffset.UTC). If it is a local time in some zone, we attach that zone first and convert with withZoneSameInstant(ZoneOffset.UTC).

ZonedDateTime inUtc = LocalDateTime.of(2026, 10, 3, 19, 30).atZone(ZoneId.of("Europe/Madrid")).withZoneSameInstant(ZoneOffset.UTC);   // 2026-10-03T17:30Z

7.4. Which Zone Should We Use if the LocalDateTime Has None?

The zone in which the value was recorded, which only the app knows. It can be the venue’s zone, the user’s profile zone or UTC for a server column. Guessing with ZoneId.systemDefault() ties the result to the server’s settings.

8. Conclusion

The method atZone(zoneId) turns a LocalDateTime into a ZonedDateTime by adding a zone, and the zone rules supply the offset for that date. ZonedDateTime.of() is the same call, whereas ofLocal() lets us choose the offset in an overlap and ofStrict() rejects invalid combinations.

A local time in a daylight saving gap moves forward, and one in an overlap gets the earlier offset, so input from users is best checked with getValidOffsets(). For the way back, toLocalDateTime() drops the zone, after a withZoneSameInstant() call if another zone’s clock is needed. The java.time tutorials page lists the other conversions.

9. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.