Date and time

java.time: Instant, LocalDateTime, ZonedDateTime, Duration and Period — and why Date and Calendar are never the answer.

11 min read Modern Java: 8 to 25

java.time (Java 8, JSR-310) replaced Date and Calendar with immutable, thread-safe, well-named types. The old classes still exist in signatures across the JDK and older libraries, but new code should touch them only at the boundary. What makes the new API work is not the naming but a strict separation the old API blurred: an instant on the timeline, a local date or time with no timeline position, and a zone that connects the two through a rules database that changes several times a year. Once that split is clear, the DST bugs stop.

The types

TypeRepresentsExample
InstantA point on the UTC timeline2026-03-01T10:15:30Z
LocalDateA date, no time, no zone2026-03-01
LocalTimeA time, no date, no zone10:15:30
LocalDateTimeDate + time, no zone2026-03-01T10:15:30
ZonedDateTimeDate + time + zone rules2026-03-01T10:15:30+05:30[Asia/Kolkata]
OffsetDateTimeDate + time + fixed offset2026-03-01T10:15:30+05:30
DurationTime-based amountPT2H30M
PeriodDate-based amountP1Y2M3D
ZoneIdA timezoneAsia/Kolkata

Rule 1: store and transmit Instant (or an ISO-8601 string of it). Rule 2: convert to ZonedDateTime only at the edge, to display or to interpret user input. Rule 3: never use LocalDateTime for a moment in time — it does not know when it is.

Under the hood: an epoch, a rules table, and two ways to be wrong

Instant is two fields: long seconds since 1970-01-01T00:00:00Z and int nanos. It has no zone because it is the timeline; there is nothing to disagree about. LocalDateTime is LocalDate + LocalTime, packed fields of year, month, day, hour, minute, second, nano, with no offset stored anywhere, which is why it cannot be converted to an Instant without being told a zone.

ZoneId.of("Asia/Kolkata") looks up a ZoneRules object from the tzdb, the IANA time zone database shipped inside the JDK (lib/tzdb.dat) and updated in every JDK patch release, because governments change DST rules with weeks of notice. A ZoneRules is a sorted table of transitions: at instant T the offset changes from +01:00 to +02:00. ZonedDateTime holds a LocalDateTime, a ZoneOffset and the ZoneId, and every arithmetic operation goes back to the rules to recompute the offset.

That recomputation is where DST lives. A gap (spring forward, 02:00 → 03:00) means a local time like 02:30 does not exist; ZonedDateTime.of(...) shifts it forward by the gap length to 03:30. An overlap (fall back, 03:00 → 02:00) means 02:30 exists twice; the API picks the earlier offset by default, and withEarlierOffsetAtOverlap() / withLaterOffsetAtOverlap() let you choose. OffsetDateTime has no rules table, only a fixed offset, so it can neither hit a gap nor represent "next Monday at 9 in Berlin"; it is the right type for a timestamp from a database column that stores the offset, and the wrong type for a future appointment.

Spring forward (gap) 01:0002:0003:0004:00 gap 02:30 does not exist:shifted to 03:30 Fall back (overlap) 01:0002:0003:0004:00 overlap 02:30 happens twice: at +02:00 (earlier, default) at +01:00 (later) Instant is unaffected: the timeline never gaps or repeats. Only local time does.
Local time is a projection of the timeline through a zone's rules; twice a year the projection tears. Store instants, and the tears are only a display problem.

Duration is seconds + nanos, exact; Period is years + months + days, calendar units whose length depends on where you add them. plusDays(1) on a ZonedDateTime across a spring-forward gives the same wall time tomorrow (23 hours later on the timeline); plus(Duration.ofHours(24)) gives 24 hours later, a different wall time. Both are correct; they answer different questions.

Getting the current time

java
Instant now = Instant.now();                          // UTC, always
ZonedDateTime local = ZonedDateTime.now(ZoneId.of("Asia/Kolkata"));
LocalDate today = LocalDate.now(ZoneId.of("Asia/Kolkata"));   // "today" depends on the zone

LocalDate.now() without a zone uses the JVM default, which is whatever the server was configured with, usually UTC in a container. "Today" in Mumbai and "today" in UTC differ for five and a half hours a day; that is the source of many "report includes yesterday's orders" bugs.

Clock: making time testable

Every now() takes a Clock. Inject one:

java
public class OrderService {
    private final Clock clock;
    public OrderService(Clock clock) { this.clock = clock; }
    public Order place(Cart cart) {
        return new Order(cart, Instant.now(clock));
    }
}
 
// Production
new OrderService(Clock.systemUTC());
// Test
new OrderService(Clock.fixed(Instant.parse("2026-03-01T10:00:00Z"), ZoneOffset.UTC));

A Clock is one abstract method, instant(), plus a zone. Clock.systemUTC() reads the OS via VM.getNanoTimeAdjustment for sub-millisecond precision since Java 9; Clock.fixed returns a constant; Clock.offset(base, duration) shifts one, useful for "what happens in 30 days" tests. Spring lets you declare a Clock bean and inject it anywhere; without one, tests that depend on time are flaky or impossible.

Parsing and formatting

java
DateTimeFormatter iso = DateTimeFormatter.ISO_INSTANT;                 // 2026-03-01T10:15:30Z
DateTimeFormatter custom = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm", Locale.ENGLISH);
 
String s = custom.format(zdt);
LocalDate d = LocalDate.parse("2026-03-01");                           // ISO by default
ZonedDateTime z = ZonedDateTime.parse("2026-03-01T10:15:30+05:30[Asia/Kolkata]");

DateTimeFormatter is immutable and thread-safe; make it a static final. That alone fixed a category of bugs the old SimpleDateFormat caused, which was mutable and corrupted its own state under concurrent use. Always pass a Locale to ofPattern; the default locale decides whether MMM prints Mar or März, and a container's default is rarely what the user expects.

Two pattern traps: YYYY is the week-based year (the last days of December can be next year), and yyyy is the year you mean; hh is 12-hour and HH is 24-hour. A formatter with YYYY is right 51 weeks a year.

Walkthrough: the appointment that moved by an hour

A clinic app stored appointments as Instant and let patients book "next Tuesday at 09:00" in Europe/Berlin.

Booking.javajava
public Instant slot(LocalDate day, LocalTime time, ZoneId zone) {
    return day.atTime(time).atZone(zone).toInstant();
}
  1. A patient booked 09:00 on the Tuesday after the clocks changed, three weeks in advance. atZone computed the offset using the rules for that date, +02:00, and stored 07:00Z. Correct.
  2. A reminder job, written by someone else, computed "24 hours before" as slot.minus(Duration.ofHours(24)) and formatted it in Berlin time: since the change happened between those two instants, 24 hours before 09:00 CEST was 08:00 CET. The reminder said "tomorrow at 08:00". Patients arrived an hour early.
  3. Another job printed the day's schedule using LocalDateTime.ofInstant(slot, ZoneOffset.UTC) formatted with HH:mm: every appointment showed two hours off, because someone had picked UTC to "avoid timezone problems".
  4. The fix was in the second job: "the day before, same wall time" is a calendar operation on the zoned value, slot.atZone(zone).minusDays(1), not a duration operation on the instant. The third job needed the clinic's zone, not UTC.
  5. The stored Instant was right the whole time. Every bug was in the projection, which is where they should be, because a projection bug is a display bug and a storage bug is a data loss.

The rule that follows: Duration for elapsed time on the timeline, Period and plusDays for calendar arithmetic, and pick per operation which question is being asked.

Boundaries: legacy and databases

  • DateInstant: date.toInstant(), Date.from(instant).
  • CalendarZonedDateTime: calendar.toInstant().atZone(zone).
  • JDBC: TIMESTAMP WITH TIME ZONEOffsetDateTime, TIMESTAMPLocalDateTime, DATELocalDate. JPA maps Instant natively since 2.2; Hibernate's hibernate.jdbc.time_zone=UTC stops it converting through the JVM default zone.
  • JSON: Jackson needs jackson-datatype-jsr310 (Spring Boot includes it) and WRITE_DATES_AS_TIMESTAMPS=false to emit ISO strings rather than epoch numbers.

The Instant at the database is the source of truth. If a column is TIMESTAMP without zone and stores UTC by convention, say so in the column comment and read it as LocalDateTime then .toInstant(ZoneOffset.UTC) — the zone is a fact about the column, not a default to be discovered.

Try it yourself

24 hours or one day?

java
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime before = ZonedDateTime.of(2026, 3, 28, 9, 0, 0, 0, berlin);  // DST starts 29 March
System.out.println(before.plusDays(1).toLocalTime() + " " + before.plus(Duration.ofHours(24)).toLocalTime());
System.out.println(Duration.between(before, before.plusDays(1)).toHours());
Answer

09:00 10:00 and 23. plusDays(1) is calendar arithmetic: same wall time next day, recomputed through the rules, which is 23 hours later on the timeline. plus(Duration.ofHours(24)) is timeline arithmetic: exactly 24 hours, which lands at 10:00 local because the clocks moved. Neither is wrong. An alarm wants the first; a "valid for 24 hours" token wants the second.

Which day is it?

A container runs with TZ=UTC. A job at 01:30 IST (20:00 UTC the previous day) runs LocalDate.now() to pick which day's orders to aggregate. What goes wrong, and what is the fix?

Answer

LocalDate.now() uses the JVM default zone, UTC, so at 01:30 IST it returns yesterday's date, and the report covers the wrong day for the first five and a half hours of every Indian day. Fix: LocalDate.now(ZoneId.of("Asia/Kolkata")), or better, LocalDate.now(clock) with a Clock that carries the business zone. Never call a zone-less now() in server code; the JVM default is a deployment accident.

Year 2027 in December 2026

DateTimeFormatter.ofPattern("YYYY-MM-dd") formats 2026-12-28 as 2027-12-28. Why, and what is the correct pattern?

Answer

YYYY is the ISO week-based year: 28 December 2026 falls in ISO week 1 of 2027 (the week containing the first Thursday of January). yyyy is the calendar year. uuuu is the proleptic year, which differs from yyyy only for dates before year 1 and is what strict parsing prefers. The formatter was right for 51 weeks a year, which is why it reached production.

Misconceptions

  • "LocalDateTime is a timestamp without the timezone hassle." It is a wall-clock reading with no position on the timeline; converting it to an instant requires a zone, and a bad guess silently shifts every value.
  • "Store everything in UTC and use OffsetDateTime." Store Instant; UTC is a display convention. OffsetDateTime cannot represent a future local appointment, because the offset then may not be the offset now.
  • "plusDays(1) and plusHours(24) are the same." Calendar versus timeline arithmetic; they differ across DST and around leap seconds' neighbours. Choose per question.
  • "The timezone database is static." It changes several times a year and ships in JDK patch releases. A JVM that is a year behind has wrong offsets for some countries.
  • "SimpleDateFormat in a static field is fine." It is mutable and not thread-safe; concurrent use corrupts output. DateTimeFormatter is immutable, and the right static.

Going deeper

  • java.time package Javadoc: the "Design notes" section, and ZoneRules for gaps and overlaps.
  • JSR 310 by Stephen Colebourne, and his Joda-Time "Why JSR-310 isn't Joda-Time" post.
  • IANA tz database and the tzdata release notes; java -XshowSettings:properties to see the JVM's tzdb version.
  • Jon Skeet, "Storing UTC is not a silver bullet", on why future local times must keep the zone.
  • Clock Javadoc, "Dependency injection" section.
Progress is saved on this device and to your account when signed in.