Java Timezones (or Time Zones if you prefer)

27 Nov 2019

Table of Contents


Introduction

The other day I was working with some old relational data, where timestamps had been stored without timezone data - and not standardized to UTC.

(By the way, read this: https://stackoverflow.com/a/32443004).

The relational data made its way into a Java module as java.util.Date values. I knew the data was captured in the local time of the database server - and the server was in the U.S. Eastern time zone.

I wanted to display the dates with an additional piece of data showing whether the datetime represented an EST or EDT value:

  • “EST” - for datetimes falling in Eastern Standard Time.
  • “EDT” - for datetimes falling in Eastern Daylight Saving Time

Also, using Java 11, I wanted to make use of classes in the java.time package.

Attempt One

After rummaging around in java.util.TimeZone, java.time.ZoneId, java.time.zone.ZoneRules, and others, my first attempt was this cumbersome thing:

Java
1
2
3
4
5
6
public static String getTimezoneAbbrev(Date date) {  
    boolean isDaylightSavings = TimeZone.getTimeZone("America/New_York").toZoneId()  
            .getRules().isDaylightSavings(date.toInstant());  
    return TimeZone.getTimeZone("America/New_York")  
            .getDisplayName(isDaylightSavings, TimeZone.SHORT);  
}  

It passed all the unit tests (see below). But I was left feeling that java.time hasn’t made things better - it’s only made them “better”.

Attempt Two

Then I noticed that java.util.TimeZone (which has existed since the dawn of time - pun intended) has a inDaylightTime(java.util.Date) method.

The newer stuff had led me down a hole.  But here is my second attempt:

Java
1
2
3
4
public static String getTimezoneAbbrev(Date date) {  
    TimeZone tz = TimeZone.getTimeZone("America/New_York");  
    return tz.getDisplayName(tz.inDaylightTime(date), TimeZone.SHORT);          
}  

More compact - not exactly nice, but a lot better.

A Warning

warning
From the Javadoc for TimeZone: Java will return “the specified TimeZone or the GMT zone if the given ID cannot be understood”.

This is dangerous because it can easily introduce silent errors into your code. It can be easy to get the time zone name wrong.

This is correct:

America/New_York

But this will cause your code to use GMT, without any warnings:

America/New-York 

The difference is subtle - an underscore (_) vs. a dash (-).

Your units tests will catch that, though… right?

Attempt Three

If I want to avoid the dangers associated with java.util.TimeZone then I can use the java.time package as follows:

Java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import java.util.Date;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

...

public static String getTimezoneAbbrev(Date date) {
    ZoneId zoneId = ZoneId.of("America/New_York");
    return date.toInstant().atZone(zoneId)
            .format(DateTimeFormatter.ofPattern("z"));
}

The z in the datetime formatter is documented as follows:

If the pattern letter is ‘z’ the output is the daylight savings aware zone name. If there is insufficient information to determine whether DST applies, the name ignoring daylight savings time will be used. If the count of letters is one, two or three, then the short name is output. If the count of letters is four, then the full name is output.

But most importantly of all, the following line, which uses that non-existent time zone name from Attempt Two…

Java
1
ZoneId.of("America/New-York")

…will now throw an exception:

java.time.zone.ZoneRulesException: Unknown time-zone ID: America/New-York

Also, as a side-note: If you just need a boolean to indicate whether or not Daylight Saving is in effect for the given date and time, then you can use this:

Java
1
boolean b = zoneId.getRules().isDaylightSavings(date.toInstant());

Anyway, Attempt Three is the best approach!

Or is it…?

Attempt Four?

The best solution would be to fix the source - in the upstream module which is providing the database data as java.util.Date objects.

The data is stored in a timestamp field in the database, without any time zone information (so, really, that needs to be fixed in the DB schema) but let’s focus on Java.

For this situation, instead of populating a Date field, the upstream code should be using java.time.LocalDateTime - where “local” here actually means (perhaps counter-intuitively) “no specific locality”.

So now we can take this zoneless date and time, tell it what time zone it is actually in, and then format it for the z timezone abbreviation:

Java
1
2
3
4
public static String getTimezoneAbbrev(LocalDateTime ldt) {
    ZoneId zoneId = ZoneId.of("America/New-York");
    return ldt.atZone(zoneId).format(DateTimeFormatter.ofPattern("z"));
}

My Unit Tests

Here are my tests for the methods using Date as the parameter:

Java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Test  
public void getTimezoneAbbrevTest() throws Exception {  
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm");  
    String tzAbbrev;  

    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2015-01-12 12:21"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2012-05-12 12:21"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2020-01-12 12:21"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2022-06-12 12:21"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    //  
    // "SPRING FORWARD" BOUNDARY TESTS  
    //  
    // Spring boundary test 1:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 01:59"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Spring boundary test 2:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 02:00"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    // Spring boundary test 3:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 03:00"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    //  
    // PAST "FALL BACK" BOUNDARY TESTS  
    //  
    // Past fall boundary test 1:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 00:59"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    // Past fall boundary test 2 - Java uses EDT for the duplicated hour:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 01:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Past fall boundary test 3 - Java uses EDT for the duplicated hour:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 01:59"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Past fall boundary test 4:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 02:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Past fall boundary test 5:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 03:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    //  
    // FUTURE "FALL BACK" BOUNDARY TESTS - just in case...  
    //  
    // Future fall boundary test 1:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-03 00:59"));  
    assertThat(tzAbbrev).isEqualTo("EDT");  

    // Future fall boundary test 2 - Java uses EDT for the duplicated hour:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 01:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Future fall boundary test 3 - Java uses EDT for the duplicated hour:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 01:59"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

    // Future fall boundary test 4:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 02:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");      

    // Future fall boundary test 5:  
    tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 03:00"));  
    assertThat(tzAbbrev).isEqualTo("EST");  

}

Dumping TZ Data

This led me to wonder what all the time zones are in Java - their names and their IDs. For example:

Standard TZ short name                   : EST  
Standard TZ long name                    : Eastern Standard Time  
Daylight saving TZ short name            : EDT  
Daylight saving TZ long name             : Eastern Daylight Time  
Local time                               : 17:38:55.355959500  
Local time with offset of GMT+02:30      : 01:08:55.371588  
Local time in current TZ                 : 17:38:55.371588  
Local TZ name using default TZ           : America/New_York  
Another way to get the same thing        : America/New_York  
Get the short TZ name                    : ET  
Get the short TZ name another way        : ET  
Get the "narrow"(?!?) TZ name            : America/New_York  
Get current date & time in the other TZ  : Nov 26, 2019, 2:38:55 PM  
Is daylight savings in effect in that TZ?: false  
Is daylight savings in effect here?      : false  

The following code generates a full listing, in case you are interested:

Java
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
package org.northcoder.timezones;  

import java.util.TimeZone;  
import java.util.Locale;  
import java.util.Map;  
import java.util.HashMap;  
import java.util.List;  
import java.util.ArrayList;  
import java.util.Collections;  
import java.io.PrintStream;  
import static java.nio.charset.StandardCharsets.UTF_8;

/**  
 * Read this: https://stackoverflow.com/a/32443004  
 */  
public class Main {  

    public static void main(String[] args) {  

        // key = timezone display name  
        // value = list of all the timezone IDs for that display name  
        Map<String, List<String>> tzMap = new HashMap();  

        // key = raw offset from GMT (millis)  
        // value = list of timezone display names  
        // we use this to sort the final output by timezone offset  
        Map<Integer, List<String>> offsets = new HashMap();  

        for (String timeZoneID : TimeZone.getAvailableIDs()) {  

            TimeZone tz = TimeZone.getTimeZone(timeZoneID);  
            String displayName = tz.getDisplayName();  
            int rawOffset = tz.getRawOffset();  

            if (offsets.containsKey(rawOffset)) {  
                if (!offsets.get(rawOffset).contains(displayName)) {  
                    offsets.get(rawOffset).add(displayName);  
                }  
            } else {  
                List<String> displayNames = new ArrayList();  
                displayNames.add(displayName);  
                offsets.put(rawOffset, displayNames);  
            }  

            if (tzMap.containsKey(displayName)) {  
                tzMap.get(displayName).add(timeZoneID);  
            } else {  
                List<String> ids = new ArrayList();  
                ids.add(timeZoneID);  
                tzMap.put(displayName, ids);  
            }  

        }  

        List<Integer> offsetsList = new ArrayList<>(offsets.keySet());  
        Collections.sort(offsetsList);  

        PrintStream out = new PrintStream(System.out, true, UTF_8);  
        out.println();  
        out.println("Java Time Zone Names and IDs");  
        out.println();  

        for (int offset : offsetsList) {  
            List<String> tzDisplayNames = offsets.get(offset);  
            Collections.sort(tzDisplayNames);  

            for (String tzDisplayName : tzDisplayNames) {  
                List<String> tzIDs = tzMap.get(tzDisplayName);  
                Collections.sort(tzIDs);  
                printDetails(tzIDs);  
            }  
        }  

    }  

    private static void printDetails(List<String> timeZoneIDs) {  

        PrintStream out = new PrintStream(System.out, true, UTF_8);  

        final boolean daylightSavingsName = true;  
        final boolean standardName = false;  

        TimeZone tz = TimeZone.getTimeZone(timeZoneIDs.get(0));  

        out.println("Time Zone Display name    : " + tz.getDisplayName());  
        out.println();  
        out.println("Display name (French)     : " + tz.getDisplayName(Locale.FRANCE));  
        out.println("Display name (Chinese)    : " + tz.getDisplayName(Locale.CHINA));  
        out.println("Display name std short    : " + tz.getDisplayName(standardName, TimeZone.SHORT));  
        out.println("Display name std long     : " + tz.getDisplayName(standardName, TimeZone.LONG));  
        out.println("Display name savings short: " + tz.getDisplayName(daylightSavingsName, TimeZone.SHORT));  
        out.println("Display name savings long : " + tz.getDisplayName(daylightSavingsName, TimeZone.LONG));  
        double offsetInHours = tz.getRawOffset() / 1000.0 / 60.0 / 60.0;  
        out.println("Offset (hours)            : " + offsetInHours);  
        out.println("Observes daylight saving  : " + tz.observesDaylightTime());  
        out.println("Time Zone IDs for this timezone: ");  
        timeZoneIDs.forEach((id) -> {  
            out.println("  - " + id);  
        });  
        out.println();  
    }  

}

An entry looks like the following - and the entries are sorted by offset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Time Zone Display name    : Western European Standard Time  

Display name (French)     : heure normale d’Europe de l’Ouest  
Display name (Chinese)    : 西欧标准时间  
Display name std short    : WET  
Display name std long     : Western European Standard Time  
Display name savings short: WEST  
Display name savings long : Western European Summer Time  
Offset (hours)            : 1.0  
Observes daylight saving  : false  
Time Zone IDs for this timezone:  
  - Africa/Casablanca  
  - Africa/El_Aaiun  
  - Atlantic/Canary  
  - Atlantic/Faeroe  
  - Atlantic/Faroe  
  - Atlantic/Madeira  
  - Europe/Lisbon  
  - Portugal