Skip to content
Codeloom
Go

Custom JSON Marshaling and Unmarshaling in Go

Implement the json.Marshaler and json.Unmarshaler interfaces to control how Go types serialize to and from JSON.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Go's json.Marshal and json.Unmarshal work with struct tags
  • Implementing the Marshaler and Unmarshaler interfaces
  • Handling enums, timestamps, and polymorphic types in JSON
  • Common patterns for backward-compatible API responses

Prerequisites

None — this post is self-contained.

Go’s encoding/json package handles most serialization through struct tags. But struct tags have limits. When you need custom date formats, enum string mappings, flattened structures, or polymorphic types, you implement the json.Marshaler and json.Unmarshaler interfaces. These two interfaces give you full control over how a type converts to and from JSON.

Struct Tags Refresher

Before diving into custom marshaling, here is what struct tags handle:

type User struct {
    ID        string `json:"id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Email     string `json:"email,omitempty"`
    Password  string `json:"-"` // never serialized
    Age       int    `json:"age,omitempty"`
}

The json:"name" tag controls the JSON key. omitempty skips zero values. - excludes the field entirely. This covers many cases, but not all.

The Marshaler Interface

To customize JSON output, implement json.Marshaler:

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

When json.Marshal encounters a type that implements this interface, it calls MarshalJSON() instead of using the default reflection-based encoding.

Custom Date Formats

Go’s time.Time marshals to RFC 3339 by default (2026-07-02T15:04:05Z). Many APIs use different formats:

type Date struct {
    time.Time
}

func (d Date) MarshalJSON() ([]byte, error) {
    formatted := d.Time.Format("2006-01-02")
    return json.Marshal(formatted)
}

func (d *Date) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return err
    }
    d.Time = t
    return nil
}

type Event struct {
    Name string `json:"name"`
    Date Date   `json:"date"`
}

Now marshaling produces {"name":"Launch","date":"2026-07-02"} instead of the full RFC 3339 timestamp.

Enum String Mapping

Go uses iota for enums, but JSON consumers expect strings:

type Status int

const (
    StatusPending  Status = iota
    StatusActive
    StatusInactive
    StatusArchived
)

var statusNames = map[Status]string{
    StatusPending:  "pending",
    StatusActive:   "active",
    StatusInactive: "inactive",
    StatusArchived: "archived",
}

var statusValues = map[string]Status{
    "pending":  StatusPending,
    "active":   StatusActive,
    "inactive": StatusInactive,
    "archived": StatusArchived,
}

func (s Status) MarshalJSON() ([]byte, error) {
    name, ok := statusNames[s]
    if !ok {
        return nil, fmt.Errorf("unknown status: %d", s)
    }
    return json.Marshal(name)
}

func (s *Status) UnmarshalJSON(data []byte) error {
    var name string
    if err := json.Unmarshal(data, &name); err != nil {
        return err
    }
    val, ok := statusValues[name]
    if !ok {
        return fmt.Errorf("unknown status: %q", name)
    }
    *s = val
    return nil
}

Usage:

type Account struct {
    Email  string `json:"email"`
    Status Status `json:"status"`
}

a := Account{Email: "alice@example.com", Status: StatusActive}
data, _ := json.Marshal(a)
// {"email":"alice@example.com","status":"active"}

Flattening Nested Structures

Sometimes the JSON shape does not match your Go struct hierarchy. Custom marshaling lets you reshape:

type Address struct {
    Street string
    City   string
    State  string
    Zip    string
}

type Customer struct {
    Name    string
    Email   string
    Address Address
}

func (c Customer) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        Name   string `json:"name"`
        Email  string `json:"email"`
        Street string `json:"street"`
        City   string `json:"city"`
        State  string `json:"state"`
        Zip    string `json:"zip"`
    }{
        Name:   c.Name,
        Email:  c.Email,
        Street: c.Address.Street,
        City:   c.Address.City,
        State:  c.Address.State,
        Zip:    c.Address.Zip,
    })
}

This produces a flat JSON object even though the Go struct is nested.

Polymorphic JSON With a Type Discriminator

APIs often return different shapes based on a type field. Handle this with a custom unmarshaler:

type Shape interface {
    Area() float64
}

type Circle struct {
    Radius float64 `json:"radius"`
}

func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }

type Rectangle struct {
    Width  float64 `json:"width"`
    Height float64 `json:"height"`
}

func (r Rectangle) Area() float64 { return r.Width * r.Height }

type ShapeWrapper struct {
    Shape Shape
}

func (sw *ShapeWrapper) UnmarshalJSON(data []byte) error {
    var raw struct {
        Type string `json:"type"`
    }
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }

    switch raw.Type {
    case "circle":
        var c Circle
        if err := json.Unmarshal(data, &c); err != nil {
            return err
        }
        sw.Shape = c
    case "rectangle":
        var r Rectangle
        if err := json.Unmarshal(data, &r); err != nil {
            return err
        }
        sw.Shape = r
    default:
        return fmt.Errorf("unknown shape type: %q", raw.Type)
    }
    return nil
}

func (sw ShapeWrapper) MarshalJSON() ([]byte, error) {
    var typeName string
    switch sw.Shape.(type) {
    case Circle:
        typeName = "circle"
    case Rectangle:
        typeName = "rectangle"
    }

    type alias ShapeWrapper
    return json.Marshal(struct {
        Type string `json:"type"`
        Shape
    }{
        Type:  typeName,
        Shape: sw.Shape,
    })
}

Now {"type":"circle","radius":5} unmarshals into a Circle and {"type":"rectangle","width":3,"height":4} into a Rectangle.

Handling Backward Compatibility

When an API field changes type between versions, custom unmarshaling handles both:

type Config struct {
    Timeout time.Duration
}

func (c *Config) UnmarshalJSON(data []byte) error {
    // Try the new format first (string like "30s")
    var obj struct {
        Timeout string `json:"timeout"`
    }
    if err := json.Unmarshal(data, &obj); err == nil && obj.Timeout != "" {
        d, err := time.ParseDuration(obj.Timeout)
        if err != nil {
            return err
        }
        c.Timeout = d
        return nil
    }

    // Fall back to old format (integer milliseconds)
    var legacy struct {
        Timeout int64 `json:"timeout"`
    }
    if err := json.Unmarshal(data, &legacy); err != nil {
        return err
    }
    c.Timeout = time.Duration(legacy.Timeout) * time.Millisecond
    return nil
}

Both {"timeout":"30s"} and {"timeout":30000} unmarshal correctly.

Using json.RawMessage for Deferred Parsing

When you need to inspect a discriminator before parsing the rest, json.RawMessage delays parsing:

type Event struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

func ParseEvent(data []byte) (*Event, error) {
    var e Event
    if err := json.Unmarshal(data, &e); err != nil {
        return nil, err
    }

    switch e.Type {
    case "user_created":
        var payload UserCreatedPayload
        if err := json.Unmarshal(e.Payload, &payload); err != nil {
            return nil, err
        }
        // handle payload
    case "order_placed":
        var payload OrderPlacedPayload
        if err := json.Unmarshal(e.Payload, &payload); err != nil {
            return nil, err
        }
        // handle payload
    }
    return &e, nil
}

json.RawMessage stores the raw bytes without parsing, letting you unmarshal into the correct type after inspecting the discriminator.

Avoiding Infinite Recursion

A common mistake is calling json.Marshal(s) inside MarshalJSON(), which calls MarshalJSON() again, creating infinite recursion. The fix is to create a type alias:

type MyType struct {
    Name  string `json:"name"`
    Extra string `json:"-"` // computed field
}

func (m MyType) MarshalJSON() ([]byte, error) {
    type Alias MyType // Alias does NOT inherit MarshalJSON
    return json.Marshal(struct {
        Alias
        FullName string `json:"full_name"`
    }{
        Alias:    Alias(m),
        FullName: m.Name + " (" + m.Extra + ")",
    })
}

The type alias Alias has the same fields but does not inherit the MarshalJSON method, breaking the recursion.

Wrap Up

Struct tags handle straightforward JSON mapping, but real-world APIs demand more. Implementing json.Marshaler and json.Unmarshaler lets you control date formats, map enum values to strings, flatten nested structures, handle polymorphic types with discriminators, and maintain backward compatibility across API versions. The pattern is consistent: take control of the bytes, parse or produce them however the API requires, and return the result.