Go's JSON Handling of time.Time: What to Watch For
Go's standard encoding/json package does a lot of the heavy lifting when it comes to serializing and deserializing time.Time values. However, if your needs go beyond the default behavior, there are a few edge cases and gotchas worth knowing about. Here's a rundown of the most significant ones.
Why Zeros and Ts Matter
The default format for time.Time in JSON is RFC 3339. As long as you're both marshaling and unmarshaling data in Go, you probably won't run into issues. But when you need to hand-craft JSON—for testing, for instance—you have to get the string right.
For example, this manually specified time string is valid and will be correctly parsed 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 few format specifics are worth noting:
- RFC 3339 (section 5.6 has a handy grammar) fully defines the format.
- An explicit UTC offset is mandatory; ambiguous local times like "8 PM" aren't allowed. This is a deliberate design choice to avoid confusion across time zones.
- The fractional seconds (e.g.,
.000) are optional and can be dropped from the string.
Be mindful of the T separator between the date and time. While RFC 3339 permits a space for readability, Go's time parser only accepts the literal T—not a lowercase t or a space.
The letter Z is also a point of contention. It's an abbreviation for Zulu time (UTC+00:00) and can only be used by itself, at the very end of the string, as a replacement for an explicit offset:
var jsonText = []byte(`
{
"name": "foobar",
"started": "2020-11-30T14:20:28.000Z"
}`)
Omitting the offset entirely is an error.
Now, the real quirk: the predefined layout constant for RFC 3339 is "2006-01-02T15:04:05Z07:00", but using that string as a value to be parsed will fail:
t, err = time.Parse(time.RFC3339, time.RFC3339)
That's because the Z in the layout is only valid as a standalone terminator; in the middle of the string, the parser expects a + or - as the offset separator. This is a known issue (see issue #20555) that's unlikely to be fixed due to backward compatibility.
Parsing Non-Standard Date Formats
So, what if you're not dealing with RFC 3339 at all? Say you need to unmarshal a date like an expiration date that has no time or timezone component. The standard json package won't handle it directly; you'll need a custom approach.
First, you need to define a layout for the time package. The critical rule for layouts is that they must describe the reference time Mon Jan 2 15:04:05 MST 2006. For a simple date without time, you might write:
customLayout := "2006-01-02" t, err := time.Parse(customLayout, "2020-11-30")
Next, define a custom type that implements the json.Unmarshaler interface via an UnmarshalJSON method:
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
}
With this type in hand, you can now substitute your CustomType wherever you would have used a time.Time field in your struct:
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. |



