Back to all articles
GoGoJSONBackendPitfalls

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.

YS
Yavuz Selim Yesilyurt
Senior Software Engineer
4 min read
Read on Medium

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.