reflect.DeepEqual compares unexported fields too — how can that make a golden comparison fail only in CI?
answer
- it sees more than your own code can
- fields you cannot name from outside
- a clock reading nobody set
- the zone the CI box runs in
- same instant, two representations
basics
~20 sreflect.DeepEqual compares every field, exported and unexported, including state you never set: a time.Time's monotonic clock reading and location pointer, or a lazily filled cache. When the CI machine's zone, clock or environment differs, those hidden fields differ and the comparison fails.
solid answer
~50 sDeep equality reaches fields your own code cannot even name. The classic case is `time.Time`, which carries an unexported wall clock, an optional monotonic reading and a `*time.Location`. A value from `time.Now()` has a monotonic reading and the local zone; the same instant decoded from a golden file has neither, so `reflect.DeepEqual` says false while `Equal` on the two times says true. That difference is invisible when you print the values, and it shows up only where the environment differs — a CI box running in UTC, a container with a different clock source, a fresh cache field populated on one side. The fix is not to keep patching comparisons: normalise before comparing (force UTC, drop the monotonic reading), inject the clock so both sides are deterministic, or compare the encoded golden bytes so the difference is textual and readable.
code
go · 11 linestype record struct {
ID string
When time.Time
}
now := time.Now() // carries a monotonic reading and the local location
a := record{ID: "x", When: now}
b := record{ID: "x", When: now.UTC()}
fmt.Println(reflect.DeepEqual(a, b)) // false
fmt.Println(a.When.Equal(b.When)) // truego deeper
Remember that deep equality looks at every field of a struct, not just the exported ones, and that a value like a timestamp carries hidden state you never set.
Explain what time.Time actually holds — a wall clock, an optional monotonic reading and a location pointer — and why two values naming the same instant can differ in representation.
Demonstrate the triage: reproduce the environment difference, find the hidden field, then fix the cause by injecting the clock or normalising at the boundary rather than weakening the comparison until it passes.
Own the standard: decide once whether recorded artefacts are compared as encoded bytes or as in-memory values, because that choice determines how reproducible every such comparison in the codebase is.
## What the rule actually says `reflect.DeepEqual`'s struct rule is one sentence: struct values are deeply equal if their corresponding fields, **both exported and unexported**, are deeply equal. There is no filter, no opt-out and no tag it respects. It reaches into every field of every struct it meets, at any depth. That is a genuine capability. Ordinary code cannot do it: you cannot name another package's unexported field, and `reflect.Value.Interface` panics on a value obtained from one, precisely to stop packages from reading each other's internals. `reflect.DeepEqual` uses an internal path that bypasses that restriction. So deep equality can compare things you could not compare by hand — and it will, whether or not those fields mean anything to you. ## The mismatch that only appears elsewhere A comparison over hidden state is only stable if the hidden state is stable, and much of it depends on the machine. **`time.Time` is the usual culprit.** It holds an unexported wall-clock encoding, an optional monotonic clock reading, and a `*time.Location`. `time.Now()` returns a value with a monotonic reading and the local location. A time reconstructed from a stored representation has no monotonic reading, and its location is whatever the parse produced — often UTC. The two can name exactly the same instant while differing in two of the three unexported fields. `Equal` on `time.Time` reports true because it compares instants; `reflect.DeepEqual` reports false because it compares representations. Now add the environment. A developer machine runs in a local zone; a CI runner typically runs in UTC. A value that keeps its location will differ between the two, so a comparison that has been passing for months goes red on a machine nobody changed. **Other hidden state behaves the same way.** A lazily populated cache field that one side has touched and the other has not. A `sync.Mutex` embedded in a struct, whose internal state differs if one copy was locked at some point. A struct holding an `error` that wraps a path, where the path contains a working directory. A field seeded from randomness or from a process id. None of these are visible in a printed diff of the exported data. ## Why the failure is so hard to triage `reflect.DeepEqual` returns a bare `bool`. It does not say which field disagreed, at what depth, or by how much. On a failing comparison you learn only "not equal", and everything you print to investigate shows the two values looking identical, because printing uses the exported, formatted view — `time.Time`'s own formatting does not show the monotonic reading unless you ask for it, and it certainly does not show a location pointer. This is where a comparison that reports a *difference* rather than a *verdict* earns its place: a structural diff, such as `cmp.Diff`, names the field and shows both sides, which turns a twenty-minute mystery into a one-line read. Keeping the two side by side — the boolean for the assertion, the diff for the failure message — is the pragmatic arrangement. ## How to make it stable In order of preference: **Compare with the type's own definition of equality.** `time.Time` has `Equal` for exactly this reason. Any type whose representation is richer than its identity should carry such a method, and your own types should too when they hold caches, clocks or pointers to shared data. **Normalise before comparing.** Converting a `time.Time` with `UTC()` both fixes the location and strips the monotonic reading, so two normalised values that name the same instant have the same representation. Do this once at the boundary where the value enters the comparison, not at every call site. **Remove the environmental input.** If a recorded structure contains timestamps at all, the recording is only reproducible when the clock is injected rather than read from the machine. A fixed clock makes the whole class of problem disappear, and it fixes the sibling problems — ordering, expiry, duration fields — at the same time. **Compare the serialised form.** For a recorded structure, encoding both sides and comparing the bytes gives you a textual difference for free, removes every unexported field from the picture, and makes the recorded artefact reviewable by a human. The trade is that you are now asserting on the encoding as well as on the data. ## The judgment to demonstrate The weak response to a comparison that fails only on CI is to make the comparison looser until it stops failing. The strong response is to ask what the hidden fields are and whether they should be part of the comparison at all. Usually the answer is that the value carries representation detail the test never meant to assert, and the right move is to compare the thing you actually care about — the instant, the decoded data, the encoded bytes — rather than the in-memory representation that happens to hold it.
- Why can't you write that unexported-field comparison yourself?You cannot name another package's unexported field, and `reflect.Value.Interface` panics on a value read from one — that restriction exists to keep packages out of each other's internals. `reflect.DeepEqual` uses an internal path that bypasses it, which is why it can compare what you cannot.
- What does DeepEqual's bare boolean cost you when a comparison fails?Everything about location. You learn "not equal" with no field name, no depth and no values, and printing both sides shows them looking identical because the differing state is unexported. Pairing the verdict with a structural diff such as `cmp.Diff` is what makes the failure readable.
- How would you make a recorded comparison stable across machines?Take the environment out of the value: inject a fixed clock instead of reading the machine's, normalise timestamps to UTC at the boundary, and prefer comparing the encoded artefact over the in-memory struct so no unexported field participates at all.
- Would switching to == have avoided this?No. `==` on a struct also compares unexported fields, so it gives the same false verdict — and it only compiles when every field is comparable, which rules out any struct holding a slice, map or func. The problem is the representation, not the operator.
saying these in an interview costs you the question
- Says DeepEqual skips unexported fields
- Assumes two times for the same instant are always deeply equal
- Blames flaky CI without checking zone or clock
- Thinks == would have given a different verdict
- Loosens the comparison until it passes instead of finding the field