Date and time
java.time: Instant, LocalDateTime, ZonedDateTime, Duration and Period — and why Date and Calendar are never the answer.
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
| Type | Represents | Example |
|---|---|---|
Instant | A point on the UTC timeline | 2026-03-01T10:15:30Z |
LocalDate | A date, no time, no zone | 2026-03-01 |
LocalTime | A time, no date, no zone | 10:15:30 |
LocalDateTime | Date + time, no zone | 2026-03-01T10:15:30 |
ZonedDateTime | Date + time + zone rules | 2026-03-01T10:15:30+05:30[Asia/Kolkata] |
OffsetDateTime | Date + time + fixed offset | 2026-03-01T10:15:30+05:30 |
Duration | Time-based amount | PT2H30M |
Period | Date-based amount | P1Y2M3D |
ZoneId | A timezone | Asia/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.
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
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 zoneLocalDate.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:
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
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.
public Instant slot(LocalDate day, LocalTime time, ZoneId zone) {
return day.atTime(time).atZone(zone).toInstant();
}- A patient booked 09:00 on the Tuesday after the clocks changed, three weeks in advance.
atZonecomputed the offset using the rules for that date, +02:00, and stored07:00Z. Correct. - 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. - Another job printed the day's schedule using
LocalDateTime.ofInstant(slot, ZoneOffset.UTC)formatted withHH:mm: every appointment showed two hours off, because someone had picked UTC to "avoid timezone problems". - 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. - The stored
Instantwas 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
Date↔Instant:date.toInstant(),Date.from(instant).Calendar↔ZonedDateTime:calendar.toInstant().atZone(zone).- JDBC:
TIMESTAMP WITH TIME ZONE↔OffsetDateTime,TIMESTAMP↔LocalDateTime,DATE↔LocalDate. JPA mapsInstantnatively since 2.2; Hibernate'shibernate.jdbc.time_zone=UTCstops it converting through the JVM default zone. - JSON: Jackson needs
jackson-datatype-jsr310(Spring Boot includes it) andWRITE_DATES_AS_TIMESTAMPS=falseto 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?
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
- "
LocalDateTimeis 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." StoreInstant; UTC is a display convention.OffsetDateTimecannot represent a future local appointment, because the offset then may not be the offset now. - "
plusDays(1)andplusHours(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.
- "
SimpleDateFormatin a static field is fine." It is mutable and not thread-safe; concurrent use corrupts output.DateTimeFormatteris immutable, and the right static.
Going deeper
java.timepackage Javadoc: the "Design notes" section, andZoneRulesfor 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
tzdatarelease notes;java -XshowSettings:propertiesto see the JVM's tzdb version. - Jon Skeet, "Storing UTC is not a silver bullet", on why future local times must keep the zone.
ClockJavadoc, "Dependency injection" section.