A subtle trap when formatting Go time.Time values

June 2, 2020

Let's say that you have a Go time.Time value, tstamp, that you happen to know for sure was collected at 18:00 and some seconds (in your time zone). Now suppose that you use time.Time.Format() on tstamp to get a string form of its hours and minutes (using the format string "15:04"). Will you always get the string '18:00' as the output from formatting tstamp this way?

Perhaps surprisingly, the answer is no; you aren't guaranteed to always get '18:00'. In theory you can get a wide variety of output instead of 18:00, although in practice often the most likely other output is whatever 18:00 in your time zone is in UTC.

What's going on is that Go time.Time values have a Location, also known as a time zone. When you format a time.Time into a string, it uses its location (time zone) to decide how to represent itself. So if tstamp's location is your local time zone, you get the 18:00 that you expect. If tstamp's location has wound up as UTC for some reason, you will get your local 18:00 in UTC, whatever that is. And so on.

Go's time.Now() specifically returns a time.Time that is located in local time, whatever that is, so if you have a timestamp that comes from that it will behave as you expect. However, time.Time values that you get from elsewhere may carry different locations along with them. In particular, if you decode a DateTime value from JSON (in Go), that time value may have carried with it a time zone which will be faithfully propagated into the time.Time value you get. Depending on what generated the JSON, that time zone may be UTC, it may be your local time zone, or it may be something else. This behavior may be surprising if your mental model of decoding a JSON date and time value is that you basically generate a Unix style seconds since epoch value that's neutral on time zones.

(JSON dates and times are commonly represented and decoded in RFC 3339 format or some variant of it.)

The solution is to say what you mean. If you want to format a time.Time value as local time, instead of whatever random time zone it came to you as, you should first make it in local time with .Local(). So instead of tstamp.Format("15:04"), use tstamp.Local().Format("15:04"). Similarly, if you want to format the time in UTC, say that with .UTC(). Unless you're sure you know what you're doing and where your time.Time values came from, using a bare .Format() is probably a mistake.

One potentially surprising place this can come up is in templates. If you have a time.Time value as a template variable, it feels natural to format it with (eg) '{{ .StartsAt.Format "15:04" }}'. But that's probably a mistake unless the time zone for the StartsAt template variable is explicitly documented. Instead, you want '{{ .StartsAt.Local.Format "15:04" }}', which will avoid current and future surprises.

Comments on this page:

By James Antill at 2020-06-07 13:49:07:

I'm not sure what you are seeing here ... but without any other calls mytime.Format("15:04") should be printing the hours/minutes within the parsed time and not doing any conversion itself. Doing the conversions are what .Local() and .UTC() are for.

Maybe you are being confused because if golang doesn't have a zoneinfo DB to query then it uses a default timezone offset of UTC itself?

If it did a conversion then how would it handle "15:04 MST" as a format?

Also see: https://play.golang.org/p/bju4Xu2o4rB

By cks at 2020-06-07 14:58:40:

Your play.golang.org example shows that Go actually has very surprising behavior when parsing time strings using an explicit name-based time zone. While Go will claim that the Location (time zone) for the parsed string is in its official time zone, it is actually treated as if it was in UTC (ie, the time zone has a 0 offset from UTC). Only using a numeric time zone offset produces results that differ:


Here we see that the playground's local time zone is UTC (which is not surprising).

You can see that the PST or other time offset is considered as having offset zero in a number of ways. To my surprise, one of them is time.Equal(); two times with different locations but the same UTC offset are apparently considered equal, and since the parsed UTC offset is zero, there you go. You can also turn the time into a Unix time (uint64) and compare it directly.


By James Antill at 2020-06-07 19:35:15:

Yeh, I alluded to that. This is documented, but still often surprising.

From: https://golang.org/pkg/time/#Parse

When parsing a time with a zone abbreviation like MST, if the zone abbreviation has a defined offset in the current location, then that offset is used. The zone abbreviation "UTC" is recognized as UTC regardless of location. If the zone abbreviation is unknown, Parse records the time as being in a fabricated location with the given zone abbreviation and a zero offset. This choice means that such a time can be parsed and reformatted with the same layout losslessly, but the exact instant used in the representation will differ by the actual zone offset. To avoid such problems, prefer time layouts that use a numeric zone offset, or use ParseInLocation

You can see this by downloading the play.golang code locally and using:

   TZ=America/Denver   ./tm "Mon Mar 29 10:02:55 MST 2016"
   TZ=America/New_York ./tm "Mon Mar 29 10:02:55 MST 2016"
By cks at 2020-06-07 20:19:49:

In the concrete case of Prometheus, what appears to happen is that Prometheus formats time.Time values into JSON with time.RFC3339Nano, which includes a numeric zone. When Prometheus formatted a default time.Time obtained from time.Now(), this propagated the offset of the local time zone into the JSON and then through to the Go code in Alertmanager that decoded it, so that when printed it came out in local time. When Prometheus switched to using time.Now().UTC() as the time source, the same time was encoded to JSON as UTC with a 0 offset and Alertmanager considered it UTC and formats it as such.

This is what I consider a confusing situation. Depending on how you choose to format time.Time values when passing them around between components, Go code may give you completely different UTC time values (on an unpredictable basis, since it partly depends on what your local time zone is) and may also later format and print them differently even for the same absolute time value.

(If you parse a time string with a named time zone and it is a valid TZ name in your local zone, like 'EDT' and 'EST' in North American Eastern time, you get the correct offset. If you parse another zone's name, like 'PST', you get a 0 offset.)

Written on 02 June 2020.
« Watching the recent AddTrust root CA certificate expiry has been humbling
In theory you (we) should have SPF records for HELO hostnames too »

Page tools: View Source, View Normal, Add Comment.
Login: Password:
Atom Syndication: Recent Comments.

Last modified: Tue Jun 2 23:11:49 2020
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.