java.time: LocalDate, ZonedDateTime and Duration
1. Overview
Handling dates and times is a familiar source of bugs: invoices off by a day, calendar meetings in the wrong timezone, jobs running twice when clocks change for daylight saving. The java.time package (Java 8+) solves most of these problems, provided you choose the right data type.
This article focuses on the three most commonly used types: LocalDate, ZonedDateTime, and Duration. By the end, you’ll know when to use each type and how to handle timezone conversions and daylight saving time (DST). You’ll also learn how to write tests that don’t depend on when they run.
2. Why use java.time
java.util.Date and Calendar have many problems. They are mutable, so not thread-safe. Months count from 0, and the name Date is misleading because it actually represents a moment in time. java.time fixes these issues:
- All objects are immutable and thread-safe. Methods like
plusDays()always return a new object. - Each concept has its own type:
| Type | Meaning | Example |
|---|---|---|
LocalDate |
Date, no time, no timezone | Birth date, expiration date |
LocalDateTime |
Date + time, no timezone | Time “on the calendar” without a location |
ZonedDateTime |
Date + time + ZoneId |
Meeting at 9 AM in Ho Chi Minh City |
Instant |
A point on the timeline (UTC) | Timestamp for DB, logs |
Duration |
Time span in seconds/nanoseconds | 30-second timeout |
Period |
Time span in years/months/days | 3-month subscription |
No extra dependency is needed because everything is in the JDK. To run the test examples, you’ll need JUnit 5 and AssertJ (already included if you use spring-boot-starter-test):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
3. LocalDate: working with dates
3.1. Creating and parsing
LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");
DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);
System.out.println(dueDate);
System.out.println(fromForm.equals(dueDate));
System.out.println(dueDate.format(vnFormat));
2024-05-15
true
15/05/2024
LocalDate.of() takes months from 1 to 12. You can also pass Month.MAY for better readability. parse() without a formatter uses the ISO-8601 standard (yyyy-MM-dd). For other formats, create a DateTimeFormatter and should declare it as static final, since formatters are also thread-safe.
Note: Don’t write
YYYYinstead ofyyyy.YYYYis week-based year. December 30, 2024 formatted withYYYYbecomes year 2025. This bug usually only surfaces at the end of the year.
3.2. Adding, subtracting, and end-of-month behavior
LocalDate orderDate = LocalDate.of(2024, 1, 31);
System.out.println(orderDate.plusDays(10));
System.out.println(orderDate.plusMonths(1));
System.out.println(orderDate.plusMonths(1).plusMonths(1));
System.out.println(orderDate.plusMonths(2));
2024-02-10
2024-02-29
2024-03-29
2024-03-31
February 2024 doesn’t have a 31st day, so plusMonths(1) falls back to the last valid day, which is 29. As a result, adding one month twice differs from adding two months at once. If you’re computing monthly billing cycles, always calculate from the original date, for example start.plusMonths(n). Don’t accumulate from the previous result.
3.3. TemporalAdjusters
Questions like “last day of month” or “next Monday” have built-in adjusters:
LocalDate today = LocalDate.of(2024, 5, 15); // Wednesday
LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());
System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01
3.4. Distance between two dates: Period and ChronoUnit
LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);
Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);
System.out.println(period);
System.out.println(period.getDays());
System.out.println(days);
P1M24D
24
55
This is a very common mistake. period.getDays() returns only the days part (24) of “1 month 24 days”, not the total number of days. For the total, use ChronoUnit.DAYS.between().
4. ZonedDateTime: dates and times tied to a timezone
4.1. Creating and changing timezones
ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");
ZonedDateTime meetingHcm = ZonedDateTime.of(
LocalDateTime.of(2024, 6, 1, 9, 0), hcm);
ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);
System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-06-01T09:00-04:00[America/New_York]
Wait, that’s not the complete output. The correct output is:
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]
These two methods differ as follows:
withZoneSameInstant()preserves the moment in time. 9 AM in Ho Chi Minh City is 10 PM on the previous day in New York (currently using daylight time, UTC-4). This is the method you need in 95% of cases, for example when displaying a meeting to someone in a different timezone.withZoneSameLocal()preserves the clock time and switches to a different moment. Use this method only when you intentionally reassign a timezone, for example if data input was accidentally given the wrong zone.
Always use IDs in the form Region/City like Europe/Berlin. Avoid abbreviations like EST or ICT because they’re ambiguous. Fixed offsets like +07:00 also say nothing about daylight saving rules.
4.2. Daylight saving time: a gap hour that “doesn’t exist”
On March 31, 2024 in Germany, clocks jump from 02:00 directly to 03:00. So 02:30 that day doesn’t exist:
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 31, 2, 30), berlin);
System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]
java.time doesn’t throw an exception but pushes the time forward by exactly the gap duration (1 hour). In the opposite direction, when daylight time ends, one hour on the clock occurs twice. By default java.time chooses the earlier offset. You can change it with withLaterOffsetAtOverlap().
4.3. Instant: what you should save to the database
When you need to store “what time an event happened”, convert to Instant (or TIMESTAMP WITH TIME ZONE in the database):
Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);
System.out.println(createdAt.atZone(newYork));
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]
The general principle is to save Instant and only convert to ZonedDateTime when displaying to a user in their timezone.
5. Duration: measuring time intervals precisely
5.1. Creating and reading Duration
Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);
System.out.println(delivery);
System.out.println(delivery.toMinutes());
System.out.printf("%d hours %d minutes%n",
delivery.toHoursPart(), delivery.toMinutesPart());
Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis());
PT2H15M
135
2 hours 15 minutes
42500
toMinutes() returns the total number of minutes. But toHoursPart() and toMinutesPart() (Java 9+) break it down into parts for display. This is the same type of trap as Period.getDays() in section 3.4.
5.2. Duration and Period: “1 day” isn’t always 24 hours
This is the most important distinction in this article:
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), berlin);
ZonedDateTime calendarDay = beforeDst.plusDays(1);
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));
System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H
The result above shows three things:
plusDays(1)adds by calendar, so keeps 12:00 noon the next day. In reality, there are only 23 hours between the two moments.plus(Duration.ofDays(1))adds exactly 24 hours, so lands at 13:00.Duration.between()confirms the real distance is 23 hours.
The rule for choosing types is simple. If the user thinks in calendar terms (“remind me every day at 9 AM”, “a 1-month package”), use Period or plusDays/plusMonths. If your system measures elapsed time (timeout, 24-hour SLA, rate limit), use Duration.
Note: You can’t add
DurationtoLocalDate, becauseLocalDate.plus(Duration.ofHours(5))throwsUnsupportedTemporalTypeException.LocalDatehas no hours, so units smaller than a day are meaningless.
6. Writing testable code with Clock
Calling LocalDate.now() directly in business logic makes tests depend on the run date. The correct way is to inject Clock:
public class SubscriptionService {
private static final int TRIAL_DAYS = 14;
private final Clock clock;
public SubscriptionService(Clock clock) {
this.clock = clock;
}
public LocalDate trialEndDate() {
return LocalDate.now(clock).plusDays(TRIAL_DAYS);
}
public boolean isExpired(LocalDate expiryDate) {
return LocalDate.now(clock).isAfter(expiryDate);
}
public Duration timeUntil(Instant deadline) {
return Duration.between(Instant.now(clock), deadline);
}
}
Every now() method in java.time has an overload that takes a Clock. In Spring Boot 3.x, declare a bean:
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
}
}
Use Clock.system(...) with an explicit zone instead of systemDefaultZone(). That way the result doesn’t change when the server runs in UTC but your dev machine is in Vietnam time.
In tests, fix the time using Clock.fixed():
class SubscriptionServiceTest {
private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
// 03:00 UTC = 10:00 AM 15/05/2024 in Ho Chi Minh City
private final Clock fixedClock =
Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
private final SubscriptionService service = new SubscriptionService(fixedClock);
@Test
void trialEndsAfterFourteenDays() {
assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
}
@Test
void expiredOnlyAfterExpiryDate() {
assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
}
@Test
void calculatesRemainingTime() {
Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
}
@Test
void calendarDayAcrossDstIsTwentyThreeHours() {
ZonedDateTime start = ZonedDateTime.of(
LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
}
}
The second test checks boundaries: a package expiring on 15/05 is still valid throughout 15/05. The last test “locks in” the DST behavior from section 5.2. If someone changes plusDays to plus(Duration.ofDays(1)), the test will report it immediately.
One detail often missed: if you use Instant.parse("2024-05-14T18:00:00Z") as a fixed time, LocalDate.now(clock) in Ho Chi Minh City will be 15/05 (1 AM). In UTC it’s still 14/05. So the Clock’s zone determines what “today” is.
7. Common mistakes and when not to use
- Using
LocalDateTimefor event timestamps.LocalDateTimedoesn’t know timezones, so when reading back you don’t know what time “10:00” refers to. For events, useInstantorOffsetDateTime/ZonedDateTime. - Using
ZonedDateTimefor pure dates. A birth date or holiday isLocalDate. If you tie a timezone to it, changing zones might shift the date to the previous day. - Saving
ZoneIdas an offset.+07:00doesn’t change with seasons, butEurope/Berlindoes. For future appointments, saveLocalDateTimewith theZoneId(as a string). DST rules can change before the appointment date, and this way of storing lets you recalculate correctly. - Confusing
getDays()/toMinutesPart()with totals. UseChronoUnit.X.between()ortoMinutes(). - Comparing with
equals()between twoZonedDateTimes in different zones.equals()compares zones too, so 9 AM in Ho Chi Minh City and 10 PM in New York at the same moment still returnfalse. To compare moments, useisEqual(),isBefore(),isAfter(). - Calling
now()without passingClockin business logic, making tests flaky based on the run date or hour.
Note:
java.timedoesn’t replace domain-specific calendar libraries. Business days, Vietnamese national holidays, or lunar calendar need custom logic or specialized libraries. Self-writtenTemporalAdjusteris a good place to encapsulate that logic.
8. Conclusion
Choosing the right data type is the most important step. Use LocalDate for calendar dates, ZonedDateTime for times tied to a location, and Instant to store moments. For time intervals, Period follows the calendar while Duration follows the clock, and in section 5.2 you saw they give different results when encountering daylight saving time.
You should also inject Clock into services and declare zones explicitly. Write tests for boundary cases like end of month, clock changes, and midnight UTC. Do these things, and most date-time bugs will be caught before they reach production.