time.Time Equality Trap: The == That Diverges from .Equal() After a JSON Round Trip2026-08-26
This service records an event, hands it to a client, and later verifies that a returned event matches what's in the cache. The comparison uses ==, which feels natural for a plain struct. Tests pass locally. In staging, every verify call returns false.
package main
import (
"encoding/json"
"fmt"
"time"
)
type Event struct {
Name string
At time.Time
}
var cache = make(map[string]Event)
func Record(name string) Event {
e := Event{Name: name, At: time.Now()}
cache[name] = e
return e
}
// Verify confirms the caller-returned event matches the cached one.
func Verify(e Event) bool {
cached, ok := cache[e.Name]
if !ok {
return false
}
return cached == e
}
func main() {
original := Record("launch")
// The client round-trips the event through JSON before sending it back.
data, _ := json.Marshal(original)
var returned Event
json.Unmarshal(data, &returned)
fmt.Println(Verify(returned)) // prints: false
}
A time.Time is not just a wall-clock instant. Since Go 1.9, values produced by time.Now() also carry a monotonic clock reading, stored in a hidden field alongside the wall-clock seconds, nanoseconds, and location pointer. That monotonic reading is what makes durations like time.Since(t) immune to wall-clock jumps (NTP steps, DST, leap seconds).
Struct equality with == compares every field of time.Time, including the monotonic reading. JSON marshalling, on the other hand, only serializes the wall-clock instant as an RFC 3339 string. When you unmarshal, you get a time.Time with the same wall-clock reading but no monotonic component and possibly a different *Location pointer (time.UTC vs. a fresh pointer). So the returned event and the cached event represent the same instant but are not ==-equal.
The same trap fires for any operation that reconstructs a time from serialized form — protobuf, msgpack, database drivers, gRPC. Local tests miss it because you're comparing the freshly-created value to itself, monotonic intact.
The Go documentation is explicit: "Because the monotonic clock reading has no meaning outside the current process, serializing a t.Round(0)... will not preserve it." And: "Two Time values are equal if they represent the same time instant... Do not use == with Time values."
Use time.Time.Equal, which compares only the wall-clock instant:
func Verify(e Event) bool {
cached, ok := cache[e.Name]
if !ok {
return false
}
return cached.Name == e.Name && cached.At.Equal(e.At)
}
If you must keep struct-level == (e.g., for map keys), strip the monotonic reading at the boundary before caching:
e := Event{Name: name, At: time.Now().Round(0)} // Round(0) drops monotonic
Round(0) is idiomatic Go for "give me an instant without the monotonic tag." After that, values that go through JSON and come back will compare equal — provided the location also matches (call .UTC() to normalize).
Same lesson, different flavor: whenever a type has "hidden" state that some operations preserve and others discard, structural equality is a landmine. Reach for the type's own Equal method.
time.Time carries a hidden monotonic clock reading that == compares but serialization discards — always use t1.Equal(t2), or strip the monotonic part with .Round(0) before storing.
