Articles / Go

  • go
  • time
  • encoding-json
  • testing
  • debugging

Why a Go time.Time Compares Unequal After a JSON Round Trip

Here is a test that fails for no visible reason. You marshal a struct holding a time.Time, unmarshal it, and the two values print the same instant, yet == or reflect.DeepEqual says they differ.

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

func main() {
	t := time.Now()
	b, err := json.Marshal(t)
	if err != nil {
		panic(err)
	}
	var u time.Time
	if err := json.Unmarshal(b, &u); err != nil {
		panic(err)
	}
	fmt.Println(t == u)     // false
	fmt.Println(t.Equal(u)) // true
}

Nothing is lost in the JSON. The nanoseconds survive, because the encoding is RFC 3339 with fractional seconds. What differs is the bookkeeping inside the struct.

A time.Time is three fields, and == compares all of them

Internally a time.Time holds a wall clock word, an extended seconds count, and a *Location pointer. Comparing with == compares those three raw fields, not the instant they describe.

Two things in that struct do not survive JSON:

  • The monotonic clock reading. time.Now() attaches one so that durations are immune to wall clock jumps. The text format has no place for it, so the decoded value has none.
  • The location pointer. JSON carries an offset such as +01:00 or Z, not a zone. The decoded value gets whatever Location the parser picks.

Either one alone is enough to make == return false.

What the parser does with the zone

For a Z suffix you get UTC. For a numeric offset, the parser checks whether it matches the local zone at that instant and, if so, uses Local. Otherwise it builds a fixed-offset location with no name.

So a value in Europe/London becomes a plain +01:00 after a round trip. The instant is right, but the zone rules are gone: add six months and the offset will not change for daylight saving. That is a real semantic loss, separate from the comparison problem.

Also, time.Now() carries a pointer to the package-level local location, while a fixed-offset parse allocates a new one. Different pointers, different struct.

Quick detour: why does == compile at all?

Because a struct of comparable fields is comparable, and that includes time.Time. The standard library documents the consequence: use Equal, since == also compares location and monotonic state. It is a loaded footgun left in place for compatibility. It also bites when you use a time.Time as a map key, where two values for the same instant can become two entries.

The fixes, in order of preference

  1. Compare with Equal. It compares the instant only. t.Equal(u) is true here.
  2. Normalise before storing or keying. t.UTC().Round(0) gives a value with no monotonic reading and a canonical location.
  3. In tests, use a comparer that knows about Equal. The go-cmp package calls an Equal method when a type has one, whereas reflect.DeepEqual does not.

Step two looks like this, and here even == holds:

t := time.Now().UTC().Round(0)
b, _ := json.Marshal(t)
var u time.Time
_ = json.Unmarshal(b, &u)
fmt.Println(t == u) // true

Round(0) is the documented way to strip the monotonic reading, and UTC() matches what the decoder produces for a Z suffix. It works, but it relies on internal representation details, so I would still reach for Equal in assertions.

Check the fields you did not mean to compare

The same trap hides in larger structures. A reflect.DeepEqual on a struct with a timestamp three levels down will fail with a diff that looks identical when printed. If a test shows two equal-looking times and still fails, print them with %#v, or look for an m=+0.0123 suffix in the default String() output, which is the monotonic reading giving itself away.

One last caveat: the monotonic reading is deliberately dropped by encoding, so do not treat a decoded timestamp as suitable for measuring elapsed time against time.Now(). It is a wall clock reading and subject to clock adjustments.