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:00orZ, not a zone. The decoded value gets whateverLocationthe 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
- Compare with
Equal. It compares the instant only.t.Equal(u)is true here. - Normalise before storing or keying.
t.UTC().Round(0)gives a value with no monotonic reading and a canonical location. - In tests, use a comparer that knows about
Equal. Thego-cmppackage calls anEqualmethod when a type has one, whereasreflect.DeepEqualdoes 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.