dates and time
Instant vs LocalDateTime vs ZonedDateTime, and daylight saving traps.
- chapter
- chapter 04.09
- reading time
- 7 min read
java.time has one type per question. Instant is a moment on the global timeline. LocalDate and LocalDateTime are what a calendar or a wall clock says, with no idea where. ZonedDateTime is a wall clock plus a zone's rules.
store and compare instants, convert to a zone only to talk to humans. and because of daylight saving, some local times never happen and some happen twice.
the picture
there's one clock for the whole universe: UTC, counted as seconds since 1970-01-01T00:00Z. every city also has a wall clock, and the wall clock is that universal time shifted by an offset that politicians choose, and change twice a year.
a LocalDateTime is a photo of a wall clock with the city cropped out. "03:30" tells you nothing about when, until someone says where. that's the root of most date bugs: code that treats a wall-clock reading as if it were a moment.
| type | date | time | offset | zone | a moment? |
|---|---|---|---|---|---|
| 1,772,955,000 s since 1970-01-01T00:00Z | yes | ||||
| 2026-03-08 | · | · | · | no | |
| · | 03:30 | · | · | no | |
| 2026-03-08 | 03:30 | · | · | no | |
| 2026-03-08 | 03:30 | -04:00 | · | yes | |
| 2026-03-08 | 03:30 | -04:00 | America/New_York | yes | |
what a wall clock says, with no idea which wall. it is NOT a moment: 03:30 in new york and 03:30 in tokyo are 13 hours apart. good for a meeting time before you know the zone, bad for anything stored or compared.
2026-03-08T07:30:00Z, held by each type. click a row. only the types with a yes actually pin down a moment; the Local ones are missing the "where".import java.time.*;
void main() {
Instant moment = Instant.parse("2026-03-08T07:30:00Z");
ZonedDateTime ny = moment.atZone(ZoneId.of("America/New_York"));
IO.println(moment);
IO.println(ny);
IO.println(ny.toLocalDateTime()); // the zone is cropped out
IO.println(ny.toOffsetDateTime()); // just the offset, no rules
IO.println(moment.atZone(ZoneId.of("Asia/Kolkata"))); // same moment, another wall
}2026-03-08T07:30:00Z 2026-03-08T03:30-04:00[America/New_York] 2026-03-08T03:30 2026-03-08T03:30-04:00 2026-03-08T13:00+05:30[Asia/Kolkata]
everything in java.time (java 8, JSR 310) is immutable and thread-safe: plusDays returns a new object, just like String. that alone fixes half the bugs of the old java.util.Date.
daylight saving: the hour that doesn't exist
on 2026-03-08, new york's clocks go from 01:59:59 straight to 03:00. so 02:30 that night isn't a time, it's a hole. on 2026-11-01 they go from 01:59:59 back to 01:00, so every minute between 01:00 and 02:00 happens twice, an hour apart.
02:30 never happens on this date: the clocks jump from 02:00 straight to 03:00. java doesn't throw, it quietly moves your time forward by the length of the gap, to 03:30.
these are the rules of ZonedDateTime.of(...) and LocalDateTime.atZone(...):
- gap: no exception. the time moves forward by the length of the gap (
02:30→03:30-04:00). - overlap: the earlier offset wins.
withLaterOffsetAtOverlap()gives you the other one,withEarlierOffsetAtOverlap()goes back. - strict: if a non-existent time should be an error,
ZonedDateTime.ofStrict(...)throwsDateTimeExceptioninstead.
this is why a job scheduled for 02:30 new york time can silently skip a day in march, one at 01:30 can run twice in november, and why you never store a LocalDateTime and hope.
Duration vs Period
two ways to say "how long", and they disagree whenever a day isn't 24 hours.
- Duration
- an exact amount of time: seconds and nanos.
PT2H15M. what a stopwatch measures. - Period
- an amount of calendar: years, months, days.
P1Y2M3D. what a calendar flips through.
var sat = ZonedDateTime.of(2026, 3, 7, 12, 0, 0, 0, ZoneId.of("America/New_York"));
IO.println(sat.plus(Period.ofDays(1))); // "same time tomorrow"
IO.println(sat.plus(Duration.ofHours(24))); // "24 hours from now"import java.time.*;
void main() {
IO.println(LocalDate.of(2026, 1, 31).plusMonths(1)); // no feb 31: clamps to the 28th
IO.println(Period.between(LocalDate.of(2026, 1, 31), LocalDate.of(2026, 3, 1)));
var ny = ZoneId.of("America/New_York");
IO.println(Duration.between(LocalDate.of(2026, 3, 8).atStartOfDay(ny),
LocalDate.of(2026, 3, 9).atStartOfDay(ny)));
IO.println(Duration.ofMinutes(135));
}2026-02-28 P1M1D PT23H PT2H15M
rule of thumb: timeouts, rate limits and measurements are Duration (on Instants). subscriptions, due dates and "every month on the 15th" are Period (on LocalDates or ZonedDateTimes).
formatting and parsing
toString() and parse() use ISO-8601 (2026-03-08T03:30-04:00) everywhere, which is what you want for anything a machine reads. for humans, DateTimeFormatter:
DateTimeFormatter.ofPattern("EEE d MMM uuuu, HH:mm", Locale.UK) // Sun 8 Mar 2026, 07:30formatters are immutable and thread-safe, so make them static final constants. (the old SimpleDateFormat is neither, and sharing one between threads corrupts dates silently.)
the old API
before java 8 there was java.util.Date (a mutable wrapper around milliseconds, whose toString uses the JVM's default zone) and Calendar (months numbered from 0, lenient by default). you'll still meet them in older libraries and JDBC code.
var c = new GregorianCalendar(2026, 1, 30); // month 1 is february
c.getTime(); // no feb 30: silently rolls to march 2LocalDate.of(2026, 2, 30); // month 2 is february
// DateTimeException: Invalid date 'FEBRUARY 30'to cross the boundary: date.toInstant() and Date.from(instant). then stay in java.time.
the rules change, so the JDK ships a database+
offsets and DST dates are set by governments, and they change: countries drop DST, move it, or switch zones, sometimes with weeks of notice. java bundles the IANA tz database (temurin 25.0.4 has release 2026b; you can ask with ZoneRulesProvider.getVersions("UTC")). JDK updates bring new rules, and oracle's tzupdater tool can patch an installed JDK in between. this is also why you store zone ids ("America/New_York"), not offsets: the id keeps meaning the right thing when the rules change.
Instantis a moment.Local*is a wall-clock reading with no place.ZonedDateTimeis both, plus the zone's rules.- store instants, convert to a zone only for humans.
- DST: in a gap java shifts forward, in an overlap it picks the earlier offset.
Durationis stopwatch time,Periodis calendar time. a day can be 23 hours.yyyy, notYYYY. and inject aClockinstead of callingnow()everywhere.