Right, so you’ve hit the point where encoding/json’s default behavior just isn’t cutting it. Maybe you need to send data in a snake_case API but your structs are in Go’s CamelCase. Maybe you need to parse a date string that looks nothing like time.RFC3339. Or perhaps you need to marshal a struct into something that isn’t a JSON object for once. This is where you roll up your sleeves and implement the json.Marshaler and json.Unmarshaler interfaces. They’re your escape hatch from the library’s sometimes overly-opinionated defaults.

Think of these interfaces as you telling the json package: “Step aside, I’ve got this.” It’s a powerful but sharp tool. Wield it correctly, and you have absolute control. Wield it carelessly, and you’ll be debugging why your carefully crafted JSON is suddenly a string containing a string containing your actual JSON. (Yes, that happens. We’ll get to that).

The MarshalJSON Escape Hatch

The json.Marshaler interface is dead simple. It has one method: MarshalJSON() ([]byte, error). Your job in this method is to return the slice of bytes that should represent your object in JSON.

Let’s say you have a NetworkAddress type that you want to marshal as a simple string "host:port" instead of a JSON object.

type NetworkAddress struct {
    Host string
    Port int
}

// The default marshaling would produce: {"Host":"example.com","Port":443}
// We want: "example.com:443"

func (a NetworkAddress) MarshalJSON() ([]byte, error) {
    // This is the critical part. We're not just returning a string.
    // We have to return a valid JSON *string*, which means it must be quoted.
    formatted := fmt.Sprintf("\"%s:%d\"", a.Host, a.Port)
    return []byte(formatted), nil
}

Why the extra quotes? Because fmt.Sprintf gives you example.com:443, but JSON requires a string value to be quoted. Without them, you’d output example.com:443, which is invalid JSON. The marshaller trusts you completely. It takes your []byte and slaps it directly into the output stream. This is both the power and the primary pitfall.

The UnmarshalJSON Pit of Success

The counterpart, json.Unmarshaler, is where things get trickier. Its method is UnmarshalJSON([]byte) error. You are given the raw bytes of the JSON value that represents your object. Your job is to parse those bytes and set your struct’s fields accordingly.

Implementing this for our NetworkAddress:

func (a *NetworkAddress) UnmarshalJSON(data []byte) error {
    // First, check if the data is a JSON string by trying to unquote it.
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }

    // Now parse our custom "host:port" format
    parts := strings.Split(s, ":")
    if len(parts) != 2 {
        return fmt.Errorf("invalid network address format: %s", s)
    }

    port, err := strconv.Atoi(parts[1])
    if err != nil {
        return err
    }

    a.Host = parts[0]
    a.Port = port
    return nil
}

See what we did there? We used the standard json.Unmarshal inside our custom unmarshaler to handle the first step—getting the raw string out of its JSON quotes. This is a best practice. You lean on the library for the grunt work of parsing basic JSON types, so you don’t have to manually account for escape sequences and other horrors.

The Recursive Descent Trap

Here’s the classic rookie mistake, the one that gets everyone. Let’s look at a broken implementation:

// ❌ DANGER: DO NOT DO THIS ❌
func (a *NetworkAddress) UnmarshalJSON(data []byte) error {
    // This data is the quoted string, e.g., '"example.com:443"'
    s := string(data)      // This gives you `"example.com:443"`
    s = strings.Trim(s, `"`) // Trim the quotes: `example.com:443`
    // ... parse s ...
}

This is fatally flawed. What if the string contains quotes inside it? "foo\"bar:8080" is valid JSON. Your naive Trim would break it horribly, leaving malformed JSON in your wake. You are not parsing a string; you are parsing JSON. Never manually trim quotes. Always use json.Unmarshal to convert the []byte into a Go string (or number, bool, etc.) for you. This rule is non-negotiable.

When to Use This Power (And When Not To)

Custom marshaling is your go-to for:

  • Serializing non-standard types: Marshaling a time.Time into a custom format.
  • Adapting to external APIs: Converting CamelCase to snake_case or parsing weird date formats.
  • Creating scalar representations: Like our NetworkAddress example.

But be warned: it’s a sledgehammer. For simpler tasks, you’re often better off using struct field tags (json:"field_name") or implementing a fmt.Stringer and using a wrapper type. The custom interfaces are all-or-nothing. If you implement them, the standard json tags on the struct’s fields are completely ignored. The library hands you the keys and walks away.

So use it, but respect it. Test your marshaling and unmarshaling with edge cases—strings with quotes, emoji, large numbers. And for the love of all that is good, never manually construct JSON by hand with fmt.Sprintf unless you’re absolutely certain of your input. The encoding/json library is there to help, even when you’re telling it to step aside.