Java Date and Time API explained by example

By | 24th January 2021

The handling of dates and times is a sure source of pain and confusion. While time is a familiar concept that everybody knows intuitively, it is difficult to formalise when developing an application.

This post aims to shed some light by presenting different use cases implemented with Java Date and Time API.

Flight tickets

The departure and arrival times in a flight ticket are always in the corresponding local time:

  • departure time is the local time in the location of departure
  • arrival time is the local time in the location of arrival

Certainly, this convention simplifies the logistics of the trip when it comes to arranging transport to/from the airport, connections with other flights, etc.

However, in order to work out the flight duration, it is necessary to factor in the time zones of the origin and destination.

Let’s consider a flight from London to Paris, departing at 2021-03-01 14:00 and arriving at 2021-03-01 16:10

ZoneId parisZoneId = ZoneId.of("Europe/Paris");
ZoneId londonZoneId = ZoneId.of("Europe/London");  

  @Test
  public void flightTickets() {
    //datetimes in tickets are always in local time
    var departureTime = LocalDateTime.of(2021,3,1,14,00,0);
    var arrivalTime = LocalDateTime.of(2021,3,1,16,10,0);

    //what's the flight duration?

    //wrong answer as LocalDateTime is not aware of time zone offsets
    assertEquals("PT2H10M", Duration.between(departureTime, arrivalTime).toString());

    //we need to take into account the time zones: departure from London, arrival to Paris
    var zonedDepartureTime = ZonedDateTime.of(departureTime, londonZoneId);
    var zonedArrivalTime = ZonedDateTime.of(arrivalTime, parisZoneId);

    //correct answer
    assertEquals("PT1H10M", Duration.between(zonedDepartureTime, zonedArrivalTime).toString());
  }

Travelling during DST changes

What if the Daylight Saving Time comes into effect during your trip?

First of all, let’s use Java API to find the next DST change (as of the time of writing this post):

jshell> import java.time.*

jshell> import java.time.zone.*

jshell> ZoneId parisZoneId = ZoneId.of("Europe/Paris");
parisZoneId ==> Europe/Paris

jshell> ZoneRules parisRules = parisZoneId.getRules();
parisRules ==> ZoneRules[currentStandardOffset=+01:00]

jshell> var now = Instant.now()
now ==> 2021-01-03T18:45:35.770460Z

jshell> parisRules.nextTransition(now)
$6 ==> Transition[Gap at 2021-03-28T02:00+01:00 to +02:00]

The next DST change in Paris will happen on 2021-03-28 at 02:00 when the clocks move forward.

With this information, let’s suppose now that our flight from London to Paris departs at 2021-03-28 00:00 and arrives at 2021-03-01 03:10. Will the application calculate the duration correctly?

The answer is “yes” as ZonedDateTime knows all the rules of its time zone.

  @Test
  public void travelDuringDstChange() {
    //what if the flight happens when clocks move forward from winter to summer time
    var departureTime = LocalDateTime.of(2021,3,28,0,0,0);
    var arrivalTime = LocalDateTime.of(2021,3,28,3,10,0);

    //what's the flight duration?

    //wrong answer as LocalDateTime is not aware of DST rules
    assertEquals("PT3H10M", Duration.between(departureTime, arrivalTime).toString());

    //ZonedDateTime computes the DST though
    var zonedDepartureTime = ZonedDateTime.of(departureTime, londonZoneId);
    var zonedArrivalTime = ZonedDateTime.of(arrivalTime, parisZoneId);

    //correct answer
    assertEquals("PT1H10M", Duration.between(zonedDepartureTime, zonedArrivalTime).toString());
  }

Non-existent times

You may have noticed that when computing the DST transition in the previous example, we got

jshell> parisRules.nextTransition(now)
$6 ==> Transition[Gap at 2021-03-28T02:00+01:00 to +02:00]

There are 2 types of DST transitions: gaps and overlaps. When the clocks move forward, there is a gap as the clocks won’t display any time between 2am and 3am. How can this situation affect an application?

Imagine you are a sys admin managing servers in different locations. You need to schedule a task to run every day on each server before 3am local time. Therefore, you schedule the task to run at 2:30am. What will happen in the morning of 2021-03-28 in the server located in Paris?

Obviously, 2021-03-28T02:30 is an invalid time in Paris (clocks jump from 02:00+01:00 to 03:00+02:00). In this case Java API falls back to summer time and as a result, the task runs after 3am!

An alternative would be to have the scheduler verify the validity of the local date and time in each time zone so that the user gets alerted. This check may be based on the fact that there is no valid offset for the gap time.

  @Test
  public void nonExistentLocalDateTime() {

    //sys admin scheduling a task to run on servers in multiple regions
    //task must run before 3am local time and therefore is scheduled at 2:30

    //sys admin sets the time specifying a local date-time
    //the scheduler converts that time into the corresponding execution instant in each time zone
    var scheduledLocalDateTime = LocalDateTime.of(2021,3,28,2,30,0);

    //if we put that date into Paris time zone
    var parisZonedDateTime = ZonedDateTime.of(scheduledLocalDateTime, parisZoneId);

    //2021-03-28T02:30 is an invalid date in Paris time zone (clocks jump from 02:00+01:00 to 03:00+02:00)
    //Therefore Java falls back to summer time to represent the date in that time zone
    //As a result, the task runs after 3am!
    assertEquals("2021-03-28T03:30+02:00[Europe/Paris]", parisZonedDateTime.toString());

    //ALTERNATIVES
    //have the scheduler verify the validity of the local date time in each time zone so that
    //the user gets alerted

    //There is no valid offset for the gap time as the clock jumps
    assertTrue(parisRules.getValidOffsets(parisDstGap).isEmpty());

    assertThrows(IllegalArgumentException.class, () -> {
      if(parisRules.getValidOffsets(scheduledLocalDateTime).isEmpty()) throw new IllegalArgumentException();
    });
  }

Duplicate times

Now the reverse of the coin: when the clocks are set back to change from summer to winter time, the clocks show the same time twice (times overlap):

jshell> var instant = Instant.parse("2021-09-03T00:00:00Z")
instant ==> 2021-09-03T00:00:00Z

jshell> parisRules.nextTransition(instant)
$9 ==> Transition[Overlap at 2021-10-31T03:00+02:00 to +01:00]

So what will happen to our scheduler? The way the Java API deals with this ambiguity is by defaulting to use (again) summer time, resulting in the task running the first time the clock shows 2:30am.

In case we want the task to run the second time the clock shows 2:30am, there are two options available:

  • make use of the method withLaterOffsetAtOverlap
  • notify the user based on the fact that there are two valid offsets during the overlap
  @Test
  public void duplicateLocalDateTime() {

    //similar to the previous one but now the local date time specified can be mapped to 2 different instants
    var scheduledLocalDateTime = LocalDateTime.of(2021,10,31,2,30,0);

    //if we put that date into Paris time zone
    var parisZonedDateTime = ZonedDateTime.of(scheduledLocalDateTime, parisZoneId);

    //2021-03-28T02:30 can be mapped to 2 instants in Paris time zone (clocks are set back from 03:00+02:00 to 02:00+01:00)
    //Again Java falls back to summer time to represent the date in that time zone
    //As a result, the task runs the first time the clock shows 02:30
    assertEquals("2021-10-31T02:30+02:00[Europe/Paris]", parisZonedDateTime.toString());

    //ALTERNATIVES: if instead we need the task to run the second time the clock shows 02:30, we can

    //1. configure the application to do that
    assertEquals("2021-10-31T02:30+01:00[Europe/Paris]", parisZonedDateTime.withLaterOffsetAtOverlap().toString());

    //2. have the scheduler verify the validity of the local date time in each time zone so that
    // the user gets alerted
    //There are 2 offsets for the overlap time as the clock moves twice through that time
    assertArrayEquals(parisRules.getValidOffsets(parisDstOverlap).stream().map(ZoneOffset::toString).toArray(), new String[]{"+02:00","+01:00"});

    assertThrows(IllegalArgumentException.class, () -> {
      if(parisRules.getValidOffsets(scheduledLocalDateTime).size() == 2) throw new IllegalArgumentException();
    });

    //To detect both gaps and overlaps, we can do
    assertThrows(IllegalArgumentException.class, () -> {
      if(parisRules.getValidOffsets(scheduledLocalDateTime).size() != 1) throw new IllegalArgumentException();
    });
  }

Same local time across different time zones

The naive implementation (relying on the default DST logic) of our scheduler involves calculating the instant in each time zone corresponding to the same nominal value of the scheduled local date and time.

  @Test
  public void taskSchedulerWithDefaultDstBehaviour() {
    //implementation of the scheduler discussed above (what is the execution instant in each region corresponding to the same local time?)
    var scheduledLocalDateTime = LocalDateTime.of(2021,10,31,2,30,0);
    var parisScheduledDateTime = ZonedDateTime.of(scheduledLocalDateTime, parisZoneId);

    assertEquals(
      "[2021-10-31T02:30-07:00[America/Los_Angeles], 2021-10-31T02:30-03:00[America/Sao_Paulo], 2021-10-31T02:30+02:00[Europe/Paris], 2021-10-31T02:30+09:00[Asia/Tokyo]]",
      Arrays.toString(Stream.of("America/Los_Angeles", "America/Sao_Paulo", "Europe/Paris", "Asia/Tokyo").map(region -> parisScheduledDateTime.withZoneSameLocal(ZoneId.of(region)).toString()).toArray())
    );

    //All instants are different
    assertEquals(4, Stream.of("America/Los_Angeles", "America/Sao_Paulo", "Europe/Paris", "Asia/Tokyo")
      .map(region -> parisScheduledDateTime.withZoneSameLocal(ZoneId.of(region)).toInstant()).distinct().toArray().length);
  }

Same instant across different time zones

Now the opposite scenario: let’s consider a sport event broadcast all over the globe. We need to know the local time in each country corresponding to the instant the event is happening.

  @Test
  public void sportEvent() {
    //what time is the game in different countries? (same instant expressed in different local time lines)

    var gameLocalTime = ZonedDateTime.of(LocalDateTime.of(2021, 3, 27, 18, 30, 0), ZoneId.of("America/Los_Angeles"));
    
    assertEquals(
      "[2021-03-27T18:30-07:00[America/Los_Angeles], 2021-03-27T22:30-03:00[America/Sao_Paulo], 2021-03-28T03:30+02:00[Europe/Paris], 2021-03-28T10:30+09:00[Asia/Tokyo]]",
      Arrays.toString(Stream.of("America/Los_Angeles", "America/Sao_Paulo", "Europe/Paris", "Asia/Tokyo").map(region -> gameLocalTime.withZoneSameInstant(ZoneId.of(region)).toString()).toArray())
    );

    //All instants are the same
    assertTrue(Stream.of("America/Los_Angeles", "America/Sao_Paulo", "Europe/Paris", "Asia/Tokyo")
      .map(region -> gameLocalTime.withZoneSameInstant(ZoneId.of(region)).toInstant())
      .allMatch(gameLocalTime.toInstant()::equals));

  }

It’s worth noting that transforming instants into local times can be done without any ambiguity as there is always one and only one local date and time for each instant.

Credit card expiry date

The expiry date printed on credit cards is not related to any time zone, so how can we really tell if any given card is expired or not?

In this example, the times 2021-01-31T15:00+01:00[Europe/Paris] and 2021-02-01T01:00+11:00[Australia/Sydney] correspond to the same instant. And yet, a card expiring on 31/2021 will be valid according to Paris time but invalid in Sydney.

Presumably, the bank will consider the time zone of the country where the card was issued in order to solve any possible ambiguity.

  @Test
  public void expiryDate() {
    //is credit card expired?
    var card = YearMonth.of(2050, 3);
    assertTrue(!YearMonth.now().isAfter(card));

    /*
    Actually, the expiry date displayed on credit cards is not related to any time zone, therefore there is some
    ambiguity.
    Presumably, the bank will consider the time zone of the country where the card was issued. In that case, the
    above check should include that time zone
     */
    ZonedDateTime parisTime = ZonedDateTime.of(LocalDateTime.of(2021,1,31,15,0,0), ZoneId.of("Europe/Paris"));
    Clock parisClock = Clock.fixed(Instant.parse(parisTime.toInstant().toString()), ZoneId.of("Europe/Paris"));
    assertEquals("2021-01-31T15:00+01:00[Europe/Paris]", parisTime.toString());

    ZonedDateTime sydneyTime = parisTime.withZoneSameInstant(ZoneId.of("Australia/Sydney"));
    Clock sydneyClock = Clock.fixed(Instant.parse(sydneyTime.toInstant().toString()), ZoneId.of("Australia/Sydney"));
    assertEquals("2021-02-01T01:00+11:00[Australia/Sydney]", sydneyTime.toString());

    YearMonth card = YearMonth.of(2021, 1);
    assertTrue(!YearMonth.now(parisClock).isAfter(card));
    assertTrue(YearMonth.now(sydneyClock).isAfter(card));
  }

The examples in this post were run with Java 11 and are available in GitHub

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.