Why might encoding/json silently skip your MarshalText or UnmarshalText methods?
answer
- no error is ever reported
- method sets decide who satisfies the interface
- one call site works, another does not
- the write landed in a copy
- assert the bytes, not just the round trip
basics
~20 sAlmost always a receiver mistake. MarshalText on a pointer receiver is skipped whenever json holds an unaddressable value, so the type encodes structurally instead. UnmarshalText on a value receiver runs but writes to a copy, leaving the field at its zero value.
solid answer
~60 sBoth failures are silent because interface satisfaction is decided by method sets, and json falls back to structural encoding rather than erroring. If `MarshalText` is declared on `*T`, then `T` does not implement `encoding.TextMarshaler`; json will take the address for you only when the value is addressable, and `json.Marshal(v)` passes a copy, so the top-level value and its fields are not addressable and the method is never called — you get the raw struct or number instead of your text. `json.Marshal(&v)` for the same data does call it, which is exactly the kind of inconsistency that makes the bug look haunted. The mirror mistake is `UnmarshalText` on a value receiver: `*T` still satisfies the interface, so json calls it, but the assignment lands in a copy and the field stays zero with no error. The rule is `MarshalText` on the value receiver, `UnmarshalText` on the pointer receiver, and the way you find it is a table-driven round-trip test that marshals and unmarshals every interesting value and compares for equality.
code
go · 11 linestype Region uint8
// Wrong: only *Region implements encoding.TextMarshaler.
func (r *Region) MarshalText() ([]byte, error) {
return []byte(regionNames[*r]), nil
}
type Site struct{ Region Region }
b1, _ := json.Marshal(Site{Region: EU}) // {"Region":1} method skipped
b2, _ := json.Marshal(&Site{Region: EU}) // {"Region":"eu"} method calledgo deeper
Be ready to state the pairing: MarshalText on the value receiver, UnmarshalText on the pointer receiver, and know that getting it wrong produces no error at all.
Explain method sets and addressability — why only *T satisfies an interface when the method has a pointer receiver, and why a value-receiver UnmarshalText writes into a discarded copy.
Diagnose from the symptom: the same type serialising two different ways at two call sites, or a config field that is always zero. Then show the golden-byte round-trip test that pins it down and keeps it fixed.
Treat this as a reviewable convention in a shared package: receivers consistent across the type, a round-trip test required for every exported value type, so a silent format regression cannot reach the teams that depend on it.
## Why it is silent `encoding/json` decides how to encode a type once, by asking whether it implements the marshaler interfaces. If it does not, that is not an error — it is the normal case, and json encodes the type structurally. So the failure mode of a mis-declared marshaler is never a compile error and never a runtime error. It is output that looks like the type never had a method at all: a struct comes out as a JSON object of its fields, a named integer comes out as a number. ## Method sets, briefly The method set of `T` contains the methods declared with receiver `T`. The method set of `*T` contains the methods declared with receiver `T` **and** those declared with receiver `*T`. Interface satisfaction is checked against the method set, so: - `MarshalText` on `func (v T)` → both `T` and `*T` satisfy `encoding.TextMarshaler`. - `MarshalText` on `func (v *T)` → only `*T` satisfies it. That asymmetry is the whole bug. ## The marshal side: addressability When json is encoding a value of type `T` and finds that only `*T` implements the interface, it will use the pointer method **if the value it is holding is addressable** — that is, if it can legally take `&value`. It does this through reflection, and reflection is strict about it. `json.Marshal(v)` receives an `any` holding a copy of `v`. A value inside an interface is not addressable, so neither it nor any of its fields can have their addresses taken, and pointer-receiver marshalers on the top-level value and its direct fields are skipped. `json.Marshal(&v)` puts a pointer in the interface; json dereferences it and now everything reachable through that pointer is addressable, and the same methods fire. The visible symptom is a type that serialises correctly in one call site and structurally in another, or a value that has the right form in the response body but the wrong form when the same struct is logged. Other unaddressable positions behave the same way: a value stored in a map (`m[k]` is not addressable) and a value returned directly from a function call. The fix is not to remember which call site passes a pointer. It is to declare `MarshalText` on the value receiver, where it belongs — it only reads the receiver, so there is no reason for it to be a pointer method, and a value receiver makes both `T` and `*T` work everywhere. ## The unmarshal side: a write into a copy The mirror mistake goes the other way and is worse, because the method actually runs. ```go func (d Duration) UnmarshalText(text []byte) error { // wrong receiver v, err := time.ParseDuration(string(text)) if err != nil { return err } d = Duration(v) // assigns to the copy; discarded on return return nil } ``` This compiles. `*Duration` satisfies `encoding.TextUnmarshaler`, because value-receiver methods are in the pointer's method set, so json finds the interface and calls the method. The method parses correctly, returns `nil`, and json moves on satisfied. The field is left holding the zero value. There is no error to log, no panic, no vet warning at the call site. A config loader built this way starts every timeout at zero and the service dies of instant deadline expiry in production with a perfectly clean startup log. A related silent case: if the type implements neither interface, json decodes structurally, so a JSON string decoded into a named integer type yields a type error — but a JSON object decoded into a struct that expected a text form quietly fills whatever fields happen to match and leaves the rest zero. ## Finding it The diagnostic that catches every variant at once is a table-driven round-trip property test in the package that owns the type: 1. A table of interesting values: the zero value, each named constant, boundary values, a value that needs escaping, a negative value. 2. For each, `json.Marshal` the value, `json.Unmarshal` into a fresh variable, compare for exact equality. 3. Assert the intermediate bytes too, at least for one case, so a marshaler that is being skipped shows up as `{"R":1}` instead of `"eu"` rather than passing because both directions are equally broken. That third point matters more than it looks. A round-trip test alone can pass while both methods are being ignored, because structural encoding round-trips fine — it is just the wrong wire format. Asserting the bytes for one golden case is what distinguishes "round-trips" from "round-trips in the format we published". Run the test with the value **and** with a pointer to it, since those are different code paths, and include a case where the value sits inside a map and inside a slice. ## The rule to carry away Declare `MarshalText` on the value receiver and `UnmarshalText` on the pointer receiver, keep the whole method set of a type consistent about receivers, and let a golden-byte round-trip test in the owning package prove the methods are actually being called.
- Why does json.Marshal(&v) call a pointer-receiver MarshalText when json.Marshal(v) does not?Because json uses the pointer method only when it can take the value's address. `json.Marshal(v)` puts a copy inside an interface, and a value in an interface is not addressable, so the method is skipped. `json.Marshal(&v)` puts a pointer in, json dereferences it, and everything reachable through it is addressable. Passing a pointer is a workaround, not a fix — move the method to the value receiver.
- A round-trip test passes but the wire format is wrong. How is that possible?Structural encoding round-trips perfectly well: if both methods are being skipped, the value is written as a number or an object and read back as the same number or object, so equality holds. The test proves symmetry, not format. Assert the actual bytes for at least one golden case — that the encoded value is the JSON string you published — and the skipped marshaler shows up immediately.
- Does go vet catch a value-receiver UnmarshalText?Do not count on it. `go vet` has checks for some method-signature mistakes, but a method that compiles, satisfies the interface through the pointer's method set and merely assigns to its own receiver is legitimate Go in general. The reliable detector is a test that decodes a known text form and asserts the resulting value is not the zero value.
- Where else do unaddressable values bite the same way?A value stored in a map — `m[k]` cannot have its address taken — and a value returned directly from a function call. Both positions skip pointer-receiver marshalers for the same reason as `json.Marshal(v)`. Including a map-valued and a slice-valued case in the round-trip table is what makes those paths visible instead of shipping.
A pointer-receiver MarshalText is a phone number the encoder can only dial when it happens to have your address. Sometimes it does, so the bug reports itself as intermittent.
saying these in an interview costs you the question
- Expects a compile error when the receiver is wrong
- Fixes it by passing a pointer at every call site
- Says a value-receiver UnmarshalText is never called
- Trusts a round-trip test that never inspects the bytes
- Blames the encoder rather than the method set