How to Use Custom JSON Marshaling and Unmarshaling in Go

Implement MarshalJSON and UnmarshalJSON methods on your type to override default JSON encoding and decoding behavior in Go.

When the wire format doesn't match your struct

You are integrating with a third-party API. The documentation says the date field comes back as "2023-10-27". Your Go struct uses time.Time because you need to calculate durations and compare timestamps. You run the unmarshal code. The program panics or returns a zero value. The API sends a simple date string. Go expects RFC3339 with nanoseconds and timezone offsets.

You cannot change the API. You do not want to change your struct to use a string field because you lose all the time methods. You need a type that behaves like time.Time in your code but speaks the API's dialect on the wire.

Go's encoding/json package uses reflection to map JSON keys to struct fields. It handles standard types automatically. When the mapping is not one-to-one, you take control by implementing two methods: MarshalJSON and UnmarshalJSON. These methods override the default behavior. You define exactly how the data transforms between bytes and memory.

The contract: Marshaler and Unmarshaler

The encoding/json package defines two interfaces. If your type implements them, the package calls your methods instead of reflecting over fields.

The Marshaler interface requires a method that returns a JSON byte slice and an error.

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

The Unmarshaler interface requires a method that takes a JSON byte slice and returns an error.

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

When you call json.Marshal, the runtime checks if the value implements Marshaler. If it does, it calls MarshalJSON. The method must return valid JSON bytes. If you return invalid JSON, the caller receives garbage or an error depending on how they use the result.

When you call json.Unmarshal, the runtime checks if the target type implements Unmarshaler. If it does, it calls UnmarshalJSON. The input byte slice contains the raw JSON data, including quotes for strings. You must parse that data and update the struct fields.

Convention dictates that receiver names are short and match the type. Use m for MyType, u for User, t for Time. The receiver for UnmarshalJSON must be a pointer. The method needs to mutate the struct to store the parsed values. A value receiver would modify a copy, and the changes would vanish.

Minimal example: A custom date type

Here is a type that wraps time.Time and formats dates as "YYYY-MM-DD". It embeds time.Time so you get all the time methods for free.

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

// MyTime wraps time.Time with a custom JSON format.
type MyTime struct {
    time.Time
}

// MarshalJSON returns the time as a "YYYY-MM-DD" string.
// The method returns bytes and error to satisfy json.Marshaler.
func (m MyTime) MarshalJSON() ([]byte, error) {
    // Format the time and wrap in quotes to produce valid JSON string
    // The format string uses the reference time: Mon Jan 2 15:04:05 MST 2006
    formatted := m.Time.Format("2006-01-02")
    return []byte(`"` + formatted + `"`), nil
}

// UnmarshalJSON parses a "YYYY-MM-DD" string into the time field.
// The receiver is a pointer so the method can modify the struct.
func (m *MyTime) UnmarshalJSON(data []byte) error {
    // Unmarshal the raw bytes into a string to strip the JSON quotes
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return fmt.Errorf("failed to parse date string: %w", err)
    }

    // Parse the string using the custom layout
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return fmt.Errorf("invalid date format: %w", err)
    }

    // Assign the parsed time to the embedded field
    m.Time = t
    return nil
}

func main() {
    // Create a MyTime instance with the current time
    t := MyTime{Time: time.Now()}

    // Marshal to JSON bytes
    b, err := json.Marshal(t)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b)) // Output: "2023-10-27"

    // Unmarshal from JSON bytes
    var t2 MyTime
    err = json.Unmarshal([]byte(`"2023-10-27"`), &t2)
    if err != nil {
        panic(err)
    }
    fmt.Println(t2.Time) // Output: 2023-10-27 00:00:00 +0000 UTC
}

The MarshalJSON method constructs the JSON manually. It formats the time and wraps it in quotes. JSON strings must be quoted. If you return []byte("2023-10-27") without quotes, the result is invalid JSON. The UnmarshalJSON method uses json.Unmarshal to strip the quotes from the input. It parses the string and assigns the result to the embedded time.Time.

The compiler enforces the interface contract. If you change the signature, the program fails to compile.

cannot use t (type MyTime) as json.Marshaler value in argument: MyTime does not implement json.Marshaler (wrong type for method MarshalJSON)
    have MarshalJSON() string
    want MarshalJSON() ([]byte, error)

The error tells you exactly what is wrong. The return type must be []byte, not string. The method must return an error. Fix the signature and the code compiles.

How the runtime finds your code

The encoding/json package uses reflection to check for method implementations. It does not use dynamic dispatch. At compile time, the compiler verifies that your type satisfies the interface. At runtime, the package calls the method directly.

When you call json.Marshal(v), the package gets the reflection type of v. It checks if the type implements json.Marshaler. If yes, it calls the method. If no, it falls back to reflection and walks the struct fields.

This check happens recursively. If your struct contains a field of type MyTime, the package checks MyTime for the interface. It finds MarshalJSON and calls it. The field is marshaled using your custom logic.

The same logic applies to unmarshaling. The package checks the target type. If it implements UnmarshalJSON, it calls the method. The method receives the raw JSON bytes for that field. You are responsible for parsing the bytes and updating the struct.

Custom marshaling is a contract. Keep the wire format stable.

Realistic example: The recursion trap

The most common mistake in custom marshaling is infinite recursion. You want to customize how a struct is marshaled, so you implement MarshalJSON. Inside the method, you call json.Marshal on the struct to handle the other fields. The package sees the MarshalJSON method and calls it again. The stack overflows.

// BAD: This causes infinite recursion
func (u User) MarshalJSON() ([]byte, error) {
    // json.Marshal sees MarshalJSON and calls this method again
    return json.Marshal(u)
}

The compiler does not catch this. The code compiles. The program crashes at runtime with a stack overflow panic.

The fix is to break the cycle. Create a type alias that has the same fields but does not implement MarshalJSON. Marshal the alias. The package sees a new type without the method and falls back to reflection.

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

// User represents a user with a custom date format.
type User struct {
    Name    string  `json:"name"`
    Email   string  `json:"email"`
    Created MyTime  `json:"created"`
}

// UserAlias is a type alias to break the recursion cycle.
// It has the same fields but does not implement MarshalJSON.
type UserAlias User

// MarshalJSON customizes the output for the Created field.
// It marshals the alias to handle standard fields, then modifies the result.
func (u User) MarshalJSON() ([]byte, error) {
    // Marshal the alias to get a standard JSON map
    // The alias does not have MarshalJSON, so reflection is used
    alias := UserAlias(u)
    data, err := json.Marshal(alias)
    if err != nil {
        return nil, err
    }

    // Unmarshal into a map to modify the created field
    var raw map[string]interface{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return nil, err
    }

    // Override the created field with the custom format
    // The MyTime type handles its own marshaling
    raw["created"] = u.Created

    // Marshal the modified map back to bytes
    return json.Marshal(raw)
}

func main() {
    u := User{
        Name:    "Alice",
        Email:   "alice@example.com",
        Created: MyTime{Time: time.Date(2023, 10, 27, 0, 0, 0, 0, time.UTC)},
    }

    b, err := json.Marshal(u)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b))
    // Output: {"created":"2023-10-27","email":"alice@example.com","name":"Alice"}
}

The UserAlias type is identical to User but does not have the MarshalJSON method. When you marshal the alias, the package uses reflection. It produces a standard JSON object. You unmarshal that object into a map. You replace the created field with the MyTime value. The MyTime type implements MarshalJSON, so it formats the date correctly. You marshal the map back to bytes.

This approach works but involves multiple allocations. You marshal to bytes, unmarshal to a map, modify the map, and marshal again. For high-performance code, consider building the JSON manually or using a library that supports custom marshaling without the overhead.

Alias the type to break the cycle. Recursion is a stack overflow waiting to happen.

Pitfalls and compiler errors

Custom marshaling introduces several pitfalls. The compiler catches some. Others surface at runtime.

Wrong receiver type. The UnmarshalJSON method must have a pointer receiver. If you use a value receiver, the compiler rejects the code.

cannot use &t (type *MyTime) as json.Unmarshaler value in argument: *MyTime does not implement json.Unmarshaler (UnmarshalJSON method has pointer receiver)

The error message is explicit. Change the receiver to a pointer.

Invalid JSON output. MarshalJSON must return valid JSON. If you return bytes that are not valid JSON, the caller may encounter errors when they try to use the result. The package does not validate the output. It trusts your method.

Ignoring errors. The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In UnmarshalJSON, you must return errors from parsing. If you ignore an error, the struct ends up in an inconsistent state.

Pointer vs value fields. If your struct contains pointer fields, json.Marshal handles them gracefully. Null pointers become null in JSON. Custom marshaling must handle nulls explicitly. If you dereference a nil pointer, the program panics.

Public and private fields. JSON marshaling only sees exported fields. Custom methods can access unexported fields. This allows you to include internal state in the JSON output or parse data into private fields. Use this power carefully. Exposing internal state can leak sensitive information.

The compiler enforces the interface. Your code provides the implementation.

Decision: struct tags vs custom methods vs RawMessage

Go offers multiple ways to control JSON serialization. Choose the right tool for the job.

Use struct tags when you only need to rename fields, omit zero values, or change the JSON key. Tags are simple and require no extra code.

Use custom marshaling when the data format differs from the in-memory representation. Implement MarshalJSON and UnmarshalJSON when you need to transform dates, enums, or nested structures.

Use json.RawMessage when you need to defer parsing or pass JSON through without modification. json.RawMessage is a []byte type that implements Marshaler and Unmarshaler. It stores the raw JSON bytes. You can unmarshal into a struct with RawMessage fields, then parse them later in a custom method. This avoids parsing data you do not need.

Use a third-party library like jsoniter or ffjson when reflection overhead becomes a bottleneck in high-throughput services. These libraries generate optimized code or use faster reflection techniques. They support custom marshaling but require a build step or additional dependencies.

Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.

json.RawMessage is a pause button. Parse only what you need.

Where to go next