From 6f73065b05cd11ff377aaf2b4ec51296c19869ea Mon Sep 17 00:00:00 2001 From: Starfall Date: Tue, 15 Aug 2023 11:32:03 -0500 Subject: new blogpost: timezones in java --- src/blog/java-timezones.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/blog/java-timezones.md diff --git a/src/blog/java-timezones.md b/src/blog/java-timezones.md new file mode 100644 index 0000000..dd135d4 --- /dev/null +++ b/src/blog/java-timezones.md @@ -0,0 +1,81 @@ +--- +title = "Timezones in Java" +date = 2023-08-15T11:10:00-05:00 +categories = [ "software" ] +--- +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](http://web.cs.ucla.edu/~eggert/tz/tz-link.htm) 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 +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](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/format/DateTimeFormatter.html) actually includes an example of a zone-name that's a three-letter acronym! ZoneId [has a list of them included for backwards compatibility](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/ZoneId.html#SHORT_IDS) 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: + +```zsh +[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 +```java +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](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/Instant.html). + +The above Instant is 2023-08-15T15:28:39Z. + +### Example 2 +```java +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 +``` java +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. -- cgit