Why is a pointer-receiver MarshalJSON skipped when json.Marshal is passed the struct by value?
answer
- method sets, not method names
- an interface parameter holds a copy
- the copy has no address to take
- put MarshalJSON on the value receiver
basics
~20 sA method declared on *T is not in T's method set, so a T copied into json.Marshal's any parameter does not satisfy json.Marshaler. The encoder silently falls back to the default field-by-field output. Passing &v works.
solid answer
~50 s`json.Marshal` takes an `any`, so the value you pass is copied into an interface. The encoder then asks whether that dynamic type implements `json.Marshaler`. For a value of type `T`, only methods declared with receiver `T` are in the method set, so a `MarshalJSON` declared on `*T` is not found, the check fails, and the encoder falls back to the reflective struct walk — no error, no warning, just the wrong JSON. Passing `&v` gives `*T`, whose method set includes both, and the method runs. The value inside an interface also has no address, so the encoder cannot take one for you; it *can* when the value is addressable, which is why a field of type `T` inside a struct you marshal via a pointer does get its `*T` marshaler called, while the same field never does inside a map, whose values are never addressable. The habit that avoids all of it: declare `MarshalJSON` on the value receiver and `UnmarshalJSON` on the pointer receiver.
code
go · 11 linestype Money struct {
Cents int64
}
func (m *Money) MarshalJSON() ([]byte, error) {
return json.Marshal(float64(m.Cents) / 100)
}
var m Money
b1, _ := json.Marshal(m) // {"Cents":0} - Money's method set has no MarshalJSON
b2, _ := json.Marshal(&m) // 0 - found on *Moneygo deeper
Know that Go has value and pointer receivers, and that a method on *T is not available on a plain T value. Remember the practical fix: pass &v to json.Marshal, or declare MarshalJSON on the value receiver.
Explain method sets and the interface copy: json.Marshal's any parameter holds an unaddressable copy, the json.Marshaler check fails, and the encoder quietly falls back to the reflective field walk with no error at all.
Be able to debug it from the symptom — the breakpoint that never fires and output that looks like raw fields — and to say where the encoder can take an address for you (an addressable struct field) and where it never can (a map value).
Set the convention for the codebase: MarshalJSON on the value receiver, UnmarshalJSON on the pointer receiver, golden-byte tests for every type on a published wire contract. Silent-fallback bugs are cheaper to prevent by convention than to find in production payloads.
## The symptom You add `func (m *Money) MarshalJSON() ([]byte, error)`, run the service, and the payload still shows `{"Cents":1250}` instead of `12.5`. You set a breakpoint on the method: it never fires. Nothing failed, nothing logged — the custom encoding simply did not happen. This is one of the few Go bugs where the compiler, the linter and the test that only checks `err == nil` are all happy. ## Method sets, in one paragraph Go has two receiver forms. A method declared `func (t T) M()` is in the method set of both `T` and `*T`. A method declared `func (t *T) M()` is in the method set of `*T` **only**. Interface satisfaction is decided by method set, so if `MarshalJSON` is on `*Money`, then `*Money` implements `json.Marshaler` and `Money` does not. You rarely notice this when calling methods directly, because `m.MarshalJSON()` on an addressable variable compiles: the compiler silently rewrites it to `(&m).MarshalJSON()`. That convenience does not extend to interface satisfaction, and `encoding/json` is entirely a question of interface satisfaction. ## Why the copy matters `func Marshal(v any) ([]byte, error)` — the parameter is an interface. Writing `json.Marshal(m)` copies `m` into that interface value. The encoder reflects on it and does, in effect, a type check against `json.Marshaler`. With a `Money` inside, the check fails and the encoder builds a struct encoder: object, one member per exported field, names from tags. That is the JSON you saw. The value stored in an interface is also not addressable — there is no variable to point at — so the encoder cannot rescue you by taking its address. ## Where the encoder *can* rescue you Addressability is the whole story, and it is why the behaviour looks inconsistent from the outside: - `json.Marshal(m)` where `m` is a `Money`: the top-level value is unaddressable. Method **not** called. - `json.Marshal(&m)`: the dynamic type is `*Money`, which implements the interface directly. Method called. - `json.Marshal(&order)` where `order` has a field `Total Money`: the encoder reached that field through a pointer, so the field is addressable. `encoding/json` notices that `*Money` implements `Marshaler` and takes the address for you. Method called. - `json.Marshal(order)` — same struct, no pointer: the whole value, and therefore every field in it, is unaddressable. Method **not** called. The same field encodes differently depending on how its container was passed. - `json.Marshal(map[string]Money{...})`: map values are never addressable in Go — you cannot write `&m["k"]`. Method **not** called, ever. Use `map[string]*Money` if you need it. Decoding is the friendlier direction: `json.Unmarshal` *requires* a non-nil pointer, so everything it reaches is addressable and a `*T` `UnmarshalJSON` is reliably found. That is also why `UnmarshalJSON` must be on a pointer receiver: a value receiver would compile, would be promoted into `*T`'s method set, would be called — and would parse the JSON into a copy that is discarded the moment it returns, leaving the field at its zero value. ## The rule that removes the class of bug **Declare `MarshalJSON` on the value receiver; declare `UnmarshalJSON` on the pointer receiver.** A value-receiver `MarshalJSON` is in the method set of both `T` and `*T`, so it is found however the value is reached: by value, by pointer, as a field, as a map value, as a slice element. It cannot mutate anything, which is exactly right for a method whose job is to serialise. The only reason to reach for `*T` is a very large struct you refuse to copy per encode — and then you have accepted that every container of it must be reached through a pointer. The asymmetry looks odd next to the usual style advice to keep all methods of a type on the same receiver form, and it is worth saying out loud in review: these two methods have different jobs, one reads and one writes, and the encoder finds them under different conditions. ## How you catch it A round-trip test catches the decode half but not this half — encoding a `Money` by value and decoding into a `*Money` can still round-trip through the default representation. What catches it is a golden test that asserts the exact bytes for the value **as your production code passes it**: if the handler encodes a `Response` struct by value, the test must too. And in review, the question to ask on any new `func (x *X) MarshalJSON()` is simply: does every place that encodes an `X` reach it through a pointer?
- Does a field of that type inside a bigger struct behave the same way?It depends on how the outer struct is passed. Marshal the outer struct through a pointer and the field is addressable, so `encoding/json` takes its address and calls the `*T` marshaler. Marshal the outer struct by value and the field is unaddressable, so the method is skipped — the same field, two different documents.
- Why does UnmarshalJSON not suffer from the same problem?`json.Unmarshal` requires a non-nil pointer as its destination, so everything the decoder walks is addressable and `*T` methods are always available. The related trap is the mirror image: an `UnmarshalJSON` on a *value* receiver is called, parses into a copy, and leaves the real field at its zero value.
- Why is a pointer-receiver marshaler never called for values in a map?Map elements are not addressable in Go — `&m["k"]` does not compile — so the encoder has no address to take and cannot reach a `*T` method. Change the map's value type to `*T`, or move the marshaler to a value receiver.
saying these in an interview costs you the question
- Says json.Marshal always takes the address of what you give it
- Claims a value-receiver method is missing from the pointer type's method set
- Expects the compiler or go vet to flag the unused marshaler
- Blames struct tags when the custom output silently disappears
- Puts UnmarshalJSON on a value receiver and wonders why fields stay zero