Tags Go

Go's json package automatically marshals and unmarshals time.Time values [1]. If you're veering away from the defaults, however, there are some quirks to be aware of. This short post covers some of the more important ones I found.

RFC 3339 by default

While the time package lets us serialize Times in many standard or custom layouts, json has a default - RFC 3339.

If you're marshaling Time into JSON from Go and unmarshaling it back in Go, this shouldn't bother you too much. That said, you may still need to produce valid JSON objects for testing.

Here's some code with a manually crafted time value that will be accepted by the json package and unmarshaled correctly into a time.Time field:

type Event struct {
  Name    string    `json:"name"`
  Started time.Time `json:"started"`
}

var jsonText = []byte(`
{
  "name": "foobar",
  "started": "2020-11-30T14:20:28.000+07:00"
}`)

func main() {

  var e Event
  if err := json.Unmarshal(jsonText, &e); err != nil {
    log.Fatal(err)
  }

  fmt.Printf("%+v\n", e)
}

A couple of notes on the format of the time string:

  • It's fully specified in RFC 3339, which is fairly short and worth a read. The grammar in section 5.6 is particularly useful.
  • All RFC 3339 times must have UTC offsets in order to be unambiguous. When we state the time is "8 PM" we typically don't have to specify the time zone. But 8 PM in California or Jerusalem are very different times, and in some scenarios specifying the time zone is useful - e.g. for flight departure and arrival times.
  • The seconds include an optional fractional component; in the string above the .000 part can be safely omitted.

The Zs and the Ts

You'll notice that in the string 2020-11-30T14:20:28.000+07:00, date and time are separated by a T. While RFC 3339 mentions that this could be replaced by a space for readability, Go's time package doesn't seem to support this option - it has to be T (neither does it support a lowercase t, even though that's also allowed per the RFC).

The case of the Z is trickier. Z stands for "Zulu time", a military jargon for UTC+00:00 (previously known as GMT). If you don't want to specify an explicit UTC offset, you can use Z instead, like this:

var jsonText = []byte(`
{
  "name": "foobar",
  "started": "2020-11-30T14:20:28.000Z"
}`)

But you cannot omit it. The parser is fairly strict; if you want Zulu time, put the Z there explicitly.

A quirk of the Go implementation is that while the layout string for RFC 3339 is given as "2006-01-02T15:04:05Z07:00", this string is invalid as a value! That is, this code returns an error:

t, err = time.Parse(time.RFC3339, time.RFC3339)

This is rather unfortunate, but difficult to change at this point. See https://github.com/golang/go/issues/20555 for more details.

The layout constant is invalid because it uses Z as the UTC offset separator, instead of + or -. Z is only valid on its own, at the end of the string.

Unmarshaling times in different layouts

As I said above, the default layout for unmarshaling Time from JSON is RFC 3339. What to do if you receive the time in a different layout? We'll have to define a custom unmarshaler. First, let's experiment with direct Time parsing.

The time package defines several predefined layouts, and we can specify custom ones. The key thing to remember is that the layout we provide has to describe the canonical time "Mon Jan 2 15:04:05 MST 2006" (where MST stands for Mountain Standard Time, or UTC-07:00).

Say we want to unmarshal food expiration dates, so we don't care about times or time zones at all. We can write:

customLayout := "2006-01-02"
t, err := time.Parse(customLayout, "2020-11-30")

To unmarshal such times from json we'll create a custom type with an UnmarshalJSON method (which will make our type implement the json.Unmarshaler interface):

type CustomTime struct {
  time.Time
}

const expiryDateLayout = "2006-01-02"

func (ct *CustomTime) UnmarshalJSON(b []byte) (err error) {
  s := strings.Trim(string(b), "\"")
  if s == "null" {
    ct.Time = time.Time{}
    return
  }
  ct.Time, err = time.Parse(expiryDateLayout, s)
  return
}

Now we can use CustomType instead of time.Time:

type MyType struct {
  Name     string     `json:"name"`
  Expiring CustomTime `json:"expiring"`
}

var jsonText = []byte(`
{
  "name": "foobar",
  "expiring": "2020-11-30"
}`)

func main() {
  var mt MyType
  if err := json.Unmarshal(jsonText, &mt); err != nil {
    log.Fatal(err)
  }

  fmt.Printf("%+v\n", mt)
}

[1]To be precise, it's the time package that does this, in collaboration with json. The time package defines an UnmarshalJSON method for Time values.