skip to content
everything.coreApi.dateAndTime

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.

typedatetimeoffsetzonea moment?
1,772,955,000 s since 1970-01-01T00:00Zyes
2026-03-08···no
·03:30··no
2026-03-0803:30··no
2026-03-0803:30-04:00·yes
2026-03-0803:30-04:00America/New_Yorkyes
LocalDateTime

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.

one moment, 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".
Main.java
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
}
output
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.

zone =
local =
02:30
wall clock in America/New_York · 2026-03-08UTC instantsnever happens00:0001:0002:0003:0004:0005:00Z06:00Z07:00Z08:00Z
ZonedDateTime.of(LocalDateTime.parse("2026-03-08T02:30"), ZoneId.of("America/New_York"))
→ 2026-03-08T03:30-04:00[America/New_York] = 2026-03-08T07:30:00Z

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.

pick a zone and slide the wall-clock time. the lines join each local time to the UTC instant it means. in a gap, there's no line: java quietly moves your time forward. in an overlap, there are two, and java picks the earlier one unless you ask. the transitions come from the JDK's own tz database (2026b, in temurin 25.0.4), and every result was checked against real java output.

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(...) throws DateTimeException instead.

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.
predict the output
java
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"
Main.java
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));
}
output
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:

java
DateTimeFormatter.ofPattern("EEE d MMM uuuu, HH:mm", Locale.UK)   // Sun 8 Mar 2026, 07:30

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

don'tjava.util
java
var c = new GregorianCalendar(2026, 1, 30);   // month 1 is february
c.getTime();   // no feb 30: silently rolls to march 2
dojava.time
java
LocalDate.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.

one level deeper: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.

keylines
  • Instant is a moment. Local* is a wall-clock reading with no place. ZonedDateTime is 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.
  • Duration is stopwatch time, Period is calendar time. a day can be 23 hours.
  • yyyy, not YYYY. and inject a Clock instead of calling now() everywhere.