Java Joda time – the danger of time zone conversion

At work, I wrote some time zone conversion code in Java and was surprised to learn of the dangers of using Joda time library. It throws an exception during 1 hour ‘error window’ when clocks are moved forward 1 hour eg Daylight Savings Start (DST). Our legacy code used Joda time library.

Lessons learnt

  1. Avoid time zone conversion code where possible. If need to convert time, use java.time rather than joda time
  2. Use LocalDateTime if possible
  3. Or use UTC if LocalDateTime not possible

Joda Time (version 2.10.6)

Daylight savings end (Sun 5 Apr 2020) – 3am clock moves back 1 hour was ok
Daylight savings start (Sun 4 Oct 2020) – 2am clock moves forward 1 hour has 1 hour error window where it throws an exception
SYD timeJoda time SYDJoda time UTCHours diff
04/10/20 01:59:5904/10/20 01:59:5903/10/20 15:59:5910
04/10/20 02:00:0004/10/20 02:00:00Illegal instant due to (see below)n/a
04/10/20 02:59:5904/10/20 02:59:59Illegal instant due to (see below)n/a
04/10/20 03:00:0004/10/20 03:00:0003/10/20 16:00:0011
Exception = Illegal instant due to time zone offset transition (daylight savings time ‘gap‘): 2020-10-04T02:00:00.000 (Australia/Sydney)

Java Time

Luckily the java.time library available from Java 8 works as expected, correctly resolving the ‘error window’ and not throwing any exceptions
SYD timeJava time SYDJava time UTCHours diff
04/10/20 01:59:5904/10/20 01:59:5903/10/20 15:59:5910
04/10/20 02:00:0004/10/20 03:00:0003/10/20 16:00:0011
04/10/20 02:59:5904/10/20 03:59:5903/10/20 16:59:5911
04/10/20 03:00:0004/10/20 03:00:0003/10/20 16:00:0011
04/10/20 03:59:5904/10/20 03:59:5903/10/20 16:59:5911

Joda Time test code

I discovered this issue when writing some tests
@Test
void convertSydTimeToUtc_daylightSavingsStarts_beforeCutoff() {
    LocalDateTime localDateTime = new LocalDateTime(2020, 10, 4, 1, 59, 59);
    DateTime sydDateTime = localDateTime.toDateTime(sydneyTimeZone);

    DateTime utcDateTime = TimezoneConverter.convertSydToUtc(sydDateTime);

    assertThat(utcDateTime.toString()).isEqualTo("2020-10-03T15:59:59.000Z");
    int offsetInMillis = sydneyTimeZone.getOffset(sydDateTime.toInstant());
    int offsetInHours = offsetInMillis / HOURS_IN_MILLIS;
    assertThat(offsetInHours).isEqualTo(10);
}

@Test
void daylightSavingsStart_forward1Hour_throwsException_duringDeadWindowStart() {
    LocalDateTime localDateTime = new LocalDateTime(2020, 10, 4, 2, 0, 0);

    assertThatThrownBy(() -> localDateTime.toDateTime(sydneyTimeZone))
            .isInstanceOf(IllegalInstantException.class)
            .hasMessageContaining("Illegal instant due to time zone offset transition (daylight savings time 'gap')");
}

Java time test code

@Test
void convertSydTimeToUtc_daylightSavingsStarts_beforeCutoff() {
    LocalDateTime localDateTime = LocalDateTime.of(2020, 10, 4, 1, 59, 59);
    ZonedDateTime sydDateTime = localDateTime.atZone(ZoneId.of("Australia/Sydney"));

    ZonedDateTime utcDateTime = TimezoneConverter.convertSydToUtc(sydDateTime);

    assertThat(utcDateTime).isEqualTo("2020-10-03T15:59:59Z[UTC]"); // 2020-10-03T16:00Z[UTC]
    assertThat(sydDateTime.getOffset().toString()).isEqualTo("+10:00");
}

@Test
void convertSydTimeToUtc_daylightSavingsStarts_afterCutoff() {
    LocalDateTime localDateTime = LocalDateTime.of(2020, 10, 4, 2, 0, 0);
    ZonedDateTime sydDateTime = localDateTime.atZone(ZoneId.of("Australia/Sydney"));

    ZonedDateTime utcDateTime = TimezoneConverter.convertSydToUtc(sydDateTime);

    assertThat(utcDateTime).isEqualTo("2020-10-03T16:00Z[UTC]");  // 2020-10-03T16:00Z[UTC]
    assertThat(sydDateTime.getOffset().toString()).isEqualTo("+11:00");
}

Leave a Reply

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