skip to content

Why does a MarshalJSON that calls json.Marshal on its own receiver recurse forever, and what stops it?

level: middleimportance: nice to knowfreq 34%

answer

  1. the encoder does not know who called it
  2. same type, same method, one frame deeper
  3. convert to something the encoder walks normally
  4. a definition, not an alias, drops the methods

basics

~20 s

json.Marshal checks the value's type for a MarshalJSON method, finds the very method that is running, and calls it again, until the goroutine stack limit is hit. The fix is to convert to a locally declared type with the same fields and no methods.

solid answer

~50 s

Inside `func (e Event) MarshalJSON()`, calling `json.Marshal(e)` hands the encoder a value of type `Event`, which implements `json.Marshaler` — by way of the method currently executing. The encoder calls it again, and again; Go grows the goroutine stack until it passes the limit and the program dies with `fatal error: stack overflow`, which `recover` cannot catch. The standard fix is a local **type definition**: `type plain Event` creates a distinct type with identical fields and an empty method set, so `json.Marshal(plain(e))` uses the ordinary reflective walk. Note it must be a definition, not an alias — `type plain = Event` is the same type and still recurses. The pattern lets you wrap: marshal an anonymous struct that embeds `plain` and adds or overrides a field. The other way out is to marshal something else entirely: a string, a map, or a purpose-built wire struct.

code

go · 7 lines
go
type Temp float64

func (t Temp) MarshalJSON() ([]byte, error) {
	// json.Marshal(t) here would call this method again: stack overflow.
	type plain Temp
	return json.Marshal(plain(t))
}

go deeper

for a junior

Remember the shape of the bug: a MarshalJSON that marshals its own receiver calls itself. Know the escape hatch — marshal a different type, such as a string, a map, or a locally defined copy of the struct.

for a middle

Explain that methods belong to the named type, so type plain Event yields the same fields with an empty method set, and that an alias declaration does not. Be able to write the embedded-wrapper form that adds a field.

for a senior

Point out that this ends in a fatal stack overflow that recover cannot catch, distinguish it from encoding/json's cyclic-data error, and know the limit: a type definition does not strip methods promoted from an embedded field.

for a principal

Decide whether the codebase should use this pattern at all. It is compact but it hides the wire shape inside a method; on a contract other teams consume, an explicit wire struct with a conversion function is often the version you would rather review and evolve.

## The loop ```go func (e Event) MarshalJSON() ([]byte, error) { return json.Marshal(e) // never returns } ``` `json.Marshal` does not know or care where it was called from. It inspects the dynamic type of its argument, sees that `Event` implements `json.Marshaler`, and calls `MarshalJSON` — the same function, one frame deeper. There is no depth guard for this in `encoding/json`, so the recursion runs until the runtime refuses to grow the goroutine's stack any further. The program prints `runtime: goroutine stack exceeds 1000000000-byte limit` and then `fatal error: stack overflow`, and it exits. This is a *fatal error*, not a panic: a deferred `recover` in the handler will not save the process, which is what makes it so unpleasant in a long-running service. It is worth separating this from a superficially similar failure. `encoding/json` **does** detect cyclic *data* — a struct pointing back at itself through pointers, slices or maps — and returns a `json.UnsupportedValueError` mentioning a cycle. That guard is about the shape of the values being walked. Method recursion never reaches the walk at all, so nothing catches it. ## Why the obvious fixes do not work - `json.Marshal(&e)` — `*Event` also has the method (a value receiver is in both method sets), so this recurses too. - Copying into a variable first — the copy has the same type, so nothing changes. - `type plain = Event` — this is a **type alias**. An alias is not a new type; it is a second name for the same one, methods included. Still recurses. ## The fix: a local type definition ```go func (e Event) MarshalJSON() ([]byte, error) { type plain Event // a new, distinct type return json.Marshal(plain(e)) } ``` `type plain Event` is a **type definition**. The new type has the same underlying struct — same fields, same `json` tags — but methods declared on `Event` are *not* carried over to it, because methods belong to the named type they are declared on. So `plain` does not implement `json.Marshaler`, the encoder falls through to its reflective field walk, and you get the default encoding of exactly those fields. The conversion `plain(e)` is legal and free: two types with identical underlying types are convertible, and no copying of field data beyond the value copy takes place. Declaring the type *inside* the method keeps it invisible to the rest of the package, which matters: a package-level `plain` is a second name for your struct that someone will eventually use in a signature. ## The wrapper pattern On its own, marshaling `plain(e)` reproduces the default output — useless unless you change something. The point of the pattern is to change one thing while letting the reflective walk handle the rest: ```go func (e Event) MarshalJSON() ([]byte, error) { type plain Event return json.Marshal(struct { plain Kind string `json:"kind"` }{plain(e), "event"}) } ``` The anonymous struct embeds `plain`, so `plain`'s exported fields are promoted into the output as if they were declared inline, and `Kind` is added beside them. `encoding/json` promotes the exported fields of an embedded struct even when the embedded type's own name is unexported, which is what makes the lowercase `plain` work here. To *override* a field rather than add one, declare a field with the same JSON name at the outer level: the shallower field wins the conflict, and the promoted one is dropped. The symmetric trick works for decoding: inside `UnmarshalJSON`, define `type plain Event`, `json.Unmarshal(b, (*plain)(e))` to fill the ordinary fields, then apply your extra logic (defaults, required-field checks, a computed field) afterwards. ## One limit worth knowing A type definition drops methods **declared on** the source type. It does not drop methods **promoted from an embedded field**, because the embedded field is part of the struct's shape and comes along with it. If `Event` embeds a type that itself has `MarshalJSON`, then `plain` still promotes that method and `json.Marshal(plain(e))` still calls it instead of walking the fields. In that situation the definition trick is not enough — give the embedded field a name, or shadow it in the wrapper struct. ## Recognising it in review The tell is any `json.Marshal(x)` inside `x`'s own `MarshalJSON` where the argument's type is the receiver's type, however it is dressed up — through a pointer, through a variable, through an alias. If the argument's type is the receiver's type, it recurses. A single unit test that marshals the type is enough to catch it, because the failure is total and immediate rather than intermittent.

  • Why does type plain = Event not fix it?
    That form declares an alias, not a new type: `plain` and `Event` are the same type under two names, with the same method set. The encoder still finds `MarshalJSON` and recurses. Only a type *definition*, `type plain Event`, creates a distinct type whose method set is empty.
  • Is the crash a panic you can recover from in an HTTP handler?
    No. Unbounded recursion ends in `fatal error: stack overflow`, which is a runtime fatal error rather than a panic; deferred functions do not run and `recover` cannot intercept it. The process dies, which is why a single unit test that marshals the type is worth having.
  • Does the local type definition also drop methods the struct promotes from an embedded field?
    No. A type definition drops methods declared on the source type, but the embedded field is part of the struct shape, so its methods are still promoted into the new type. If the embedded type has its own `MarshalJSON`, you must name or shadow that field instead.
  • How does the same pattern look on the decoding side?
    Define `type plain Event` inside `UnmarshalJSON`, call `json.Unmarshal(b, (*plain)(e))` to let the reflective decoder fill the ordinary fields, then run your extra logic — defaults, computed fields, cross-field checks — on the populated receiver.

saying these in an interview costs you the question

  • Thinks encoding/json detects and breaks the recursion itself
  • Uses type plain = Event and reports the bug as fixed
  • Believes marshaling the pointer &e avoids the method
  • Says a deferred recover will keep the service alive
  • Confuses it with encoding/json's cyclic-data error