skip to content

Why is a pointer-receiver MarshalJSON skipped when json.Marshal is passed the struct by value?

level: middleimportance: should knowfreq 48%

answer

  1. method sets, not method names
  2. an interface parameter holds a copy
  3. the copy has no address to take
  4. put MarshalJSON on the value receiver

basics

~20 s

A 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 lines
go
type 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 *Money

go deeper

for a junior

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.

for a middle

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.

for a senior

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

for a principal

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