Go's time.Time Equality Trap: The == That Diverges from .Equal() After a JSON Round Trip

2026-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
}

The Bug

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."

The Fix

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.

Key Takeaway: A 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.

All newsletters