Timezones in Java

2023-08-15

Recently ran into an issue at work that we couldn't find a direct answer to anywhere on the Internet (thanks to the terrible state of search in the modern day after Search Engine Optimization and Large Language Models have screwed it over, but that's another topic...) relating to three-letter abbreviations for timezones.

Long story short, use canonical timezone names from tzdb like "America/New_York" instead of abbreviations like "ET". The abbreviations like EST, CDT, CET, BST... mostly don't work any more, and for good reasons. Is MST Malaysian Standard Time (UTC+8) or North America Mountain Standard Time (UTC-7)? They might be standardized within a given nation's borders, but not worldwide. So, if you run across an error that looks like this when trying to parse a date:

java.time.format.DateTimeParseException: Text '01/01/1999 - 00:00:00 EDT' could not be parsed: null
    at java.base/java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:2017)
    at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1952)
    at java.base/java.time.format.LocalDateTime.parse(LocalDateTime.java:492)
    at [ REDACTED ]

    Caused by:
    java.lang.NullPointerException
        at java.base/java.time.format.DateTimeFormatterBuilder$PrefixTree.prefixLength(DateTimeFormatterBuilder.java:4527)
        at java.base/java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4396)
        at java.base/java.time.format.DateTimeFormatterBuilder$PrefixTree.add(DateTimeFormatterBuilder.java:4391)
        at java.base/java.time.format.DateTimeFormatterBuilder$ZoneTextPrinterParser.getTree(DateTimeFormatterBuilder.java:4138)
        at java.base/java.time.format.DateTimeFormatterBuilder$ZoneTextPrinterParser.parse(DateTimeFormatterBuilder.java:4249)
        at java.base/java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.parse(DateTimeFormatterBuilder.java:2370)
        at java.base/java.time.format.DateTimeFormatter.parseUnresolved0(DateTimeFormatter.java:2107)
        at java.base/java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:2036)
        at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1948)
        ... 3 more

... it's likely that you're trying to use a three letter abbreviation for a timezone (here, "EDT" being used instead of "America/New_York").

Confusingly, the documentation for DateTimeFormatter actually includes an example of a zone-name that's a three-letter acronym! ZoneId has a list of them included for backwards compatibility but I couldn't figure out if they're actually parseable (leaning towards no).

And as an extra layer, this behavior relies on the underlying system. The abbrevations worked just fine on our work MacBook but not on the Jenkins build nodes. I don't have an answer for exactly why, but my guess is that Mac tooling happily responds with UTC as a default time zone when it doesn't know what you're asking, while GNU ones error. You can see the same kind of difference on the date program for each:

[starfall@mac:~] % TZ=unknown date
Tue Aug 15 15:28:39 UTC 2023

[starfall@arch:~] % TZ=unknown date
Tue Aug 15 03:28:39 PM unknown 2023

One solution is to just use times with offsets, but there are valid reasons to choose to use a time with timezone instead. Here's a few ways you can parse them properly.

Example 1

String stringWithTz = "2023-08-15 10:28:39 America/Chicago";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV", Locale.US);
Instant instant = Instant.from(formatter.parse(stringWithTz));

Usually you will be able to use an Instant. These are stored without any time zone or offset, just as a moment in ... something that's close enough to UTC for most work. If the details matter, read the documentation.

The above Instant is 2023-08-15T15:28:39Z.

Example 2

String stringWithTz = "2023-08-15 10:28:39 America/Chicago";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV", Locale.US);
ZonedDateTime zdt = ZonedDateTime.parse(stringWithTz, formatter);

This gets you a ZonedDateTime, which keeps the time zone information around. Usually an Instant will be fine instead, unless you really need to keep track of which datetime came from which timezone.

The above ZonedDateTime is 2023-08-15T10:28:39-05:00 (the same time as the Instant in example 1).

Example 3

String isoString = "2023-08-15T10:28:39";
ZoneId timezone = ZoneId.of("America/Chicago");
ZonedDateTime zdt = LocalDateTime.parse(isoString).atZone(timezone);
Instant instant = Instant.from(zdt);

If you don't have time zones in your strings, you can hydrate them with one like this. Keeping the LocalDateTime without a timezone is not recommended unless you have a very good reason, like they're historical dates from one location that won't be compared across timezones.

The Instant and ZonedDateTime here are the same as the previous two examples.