Custom JSON Marshaller in Go and Common Pitfalls
Deep dive into implementing custom json.Marshaler and json.Unmarshaler interfaces in Go, and avoiding infinite recursion traps.
Custom JSON Marshaller in Go and Common Pitfalls
Go's standard library encoding/json package is fast, flexible, and battle-tested. Most of the time, struct tags like `json:"created_at"` are sufficient. However, real-world APIs often demand custom transformations—such as epoch timestamp parsing, custom enum representations, or masking sensitive PII data.
Implementing json.Marshaler
To customize how a type serializes, implement the json.Marshaler interface:
type Timestamp time.Time
func (t Timestamp) MarshalJSON() ([]byte, error) {
formatted := fmt.Sprintf("\"%s\"", time.Time(t).Format("2006-01-02 15:04:05"))
return []byte(formatted), nil
}
The Infinite Recursion Trap
A very common bug occurs when implementing MarshalJSON on a struct type while attempting to marshal its default fields:
type User struct {
Name string `json:"name"`
Token string `json:"token"`
}
// ⚠️ BUG: Infinite Recursion!
func (u User) MarshalJSON() ([]byte, error) {
u.Token = "***REDACTED***"
return json.Marshal(u) // Calls MarshalJSON again!
}
Calling json.Marshal(u) inside User.MarshalJSON() re-triggers User.MarshalJSON(), causing a stack overflow crash.
The Solution: Type Aliasing
To bypass the custom method on the original type, cast to an alias type without methods:
func (u User) MarshalJSON() ([]byte, error) {
type Alias User // Alias inherits fields, but NOT methods!
return json.Marshal(&struct {
*Alias
Token string `json:"token"`
}{
Alias: (*Alias)(&u),
Token: "***REDACTED***",
})
}
By casting *Alias, the Go runtime invokes the default struct marshaller rather than re-entering our custom method.
