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 YYYY instead of yyyy. YYYY is week-based year. December 30, 2024 formatted with YYYY becomes 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 Duration to LocalDate, because LocalDate.plus(Duration.ofHours(5)) throws UnsupportedTemporalTypeException. LocalDate has 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 LocalDateTime for event timestamps. LocalDateTime doesn’t know timezones, so when reading back you don’t know what time “10:00” refers to. For events, use Instant or OffsetDateTime/ZonedDateTime.
  • Using ZonedDateTime for pure dates. A birth date or holiday is LocalDate. If you tie a timezone to it, changing zones might shift the date to the previous day.
  • Saving ZoneId as an offset. +07:00 doesn’t change with seasons, but Europe/Berlin does. For future appointments, save LocalDateTime with the ZoneId (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. Use ChronoUnit.X.between() or toMinutes().
  • Comparing with equals() between two ZonedDateTimes 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 return false. To compare moments, use isEqual(), isBefore(), isAfter().
  • Calling now() without passing Clock in business logic, making tests flaky based on the run date or hour.

Note: java.time doesn’t replace domain-specific calendar libraries. Business days, Vietnamese national holidays, or lunar calendar need custom logic or specialized libraries. Self-written TemporalAdjuster is 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.

javajava-timedatetimetimezonetesting