Skip to main content

Working with JSON in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 6 min read

JSON handling in Go is built on two ideas: struct tags map Go field names to JSON keys, and the encoding/json package handles the rest automatically. For 90% of use cases that’s all you need. But there are important details — zero values and omitempty, json.RawMessage for deferred decoding, custom marshalers for non-standard types, and the difference between json.Marshal and json.Encoder that matters when you’re streaming large responses.

For serialization format comparisons see Go data serialization formats and Go protocol buffers.

Struct Tags: Controlling the Output

Without struct tags, json.Marshal uses Go field names verbatim (Name, UserID, CreatedAt). JSON conventions use lowercase or camelCase, so always add tags:

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
    Password  string    `json:"-"`            // always omit — never expose passwords
    Bio       string    `json:"bio,omitempty"` // omit when empty string
    Score     *int      `json:"score,omitempty"` // omit when nil pointer
}

Three important tag options:

  • json:"-" — never include this field, regardless of value. Use it for passwords, internal IDs, sensitive data.
  • omitempty — omit the field when it’s the zero value: "" for strings, 0 for numbers, nil for pointers and slices, false for bools. This reduces payload size and avoids sending fields that mean “not set”.
  • string — encode a numeric value as a JSON string: json:"id,string" encodes int as "42" instead of 42. Useful for large integers that JavaScript can’t represent accurately.

Basic Marshal and Unmarshal

Encoding a struct to JSON bytes and decoding back:

user := User{
    ID:    1,
    Name:  "Alice",
    Email: "[email protected]",
    Score: func() *int { v := 95; return &v }(),
}

// Marshal: Go → JSON bytes
data, err := json.Marshal(user)
if err != nil {
    return fmt.Errorf("marshaling user: %w", err)
}
fmt.Println(string(data))
// {"id":1,"name":"Alice","email":"[email protected]","created_at":"0001-01-01T00:00:00Z","score":95}

// Unmarshal: JSON bytes → Go struct
var decoded User
if err := json.Unmarshal(data, &decoded); err != nil {
    return fmt.Errorf("unmarshaling user: %w", err)
}

Always check the error from both Marshal and Unmarshal. Marshal can fail on types that can’t be represented in JSON (channels, functions, cyclic structures). Unmarshal fails on malformed JSON or type mismatches.

Streaming: Encoder and Decoder

json.Marshal and json.Unmarshal work on complete byte slices — the entire payload must fit in memory. For HTTP handlers and large files, use json.Encoder (writes to io.Writer) and json.Decoder (reads from io.Reader) to stream without buffering the whole payload:

// ✅ Write directly to the HTTP response — no intermediate allocation
func writeResponse(w http.ResponseWriter, users []User) error {
    w.Header().Set("Content-Type", "application/json")
    return json.NewEncoder(w).Encode(users)
}

// ✅ Read request body directly — no io.ReadAll needed
func readUser(r *http.Request) (*User, error) {
    var u User
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()  // reject unknown fields — useful for strict APIs
    if err := dec.Decode(&u); err != nil {
        return nil, fmt.Errorf("decoding request: %w", err)
    }
    return &u, nil
}

DisallowUnknownFields() turns typos in field names into errors. Without it, {"naem": "Alice"} silently ignores naem and leaves Name empty. For public APIs, this is usually the safer default.

json.Decoder can also decode a stream of multiple JSON objects from a single reader — useful for NDJSON (newline-delimited JSON) log files or event streams:

dec := json.NewDecoder(r.Body)
for dec.More() {
    var event Event
    if err := dec.Decode(&event); err != nil {
        return err
    }
    process(event)
}

Handling Dynamic JSON with json.RawMessage

Sometimes you don’t know the structure of part of the JSON at decode time — a polymorphic data field that varies by type, for example. json.RawMessage defers decoding: it captures the raw JSON bytes and lets you decode them later once you know the type:

type Message struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`  // decoded later
}

func handleMessage(data []byte) error {
    var msg Message
    if err := json.Unmarshal(data, &msg); err != nil {
        return err
    }

    switch msg.Type {
    case "user_created":
        var payload UserCreatedPayload
        if err := json.Unmarshal(msg.Payload, &payload); err != nil {
            return err
        }
        return handleUserCreated(payload)
    case "order_placed":
        var payload OrderPlacedPayload
        if err := json.Unmarshal(msg.Payload, &payload); err != nil {
            return err
        }
        return handleOrderPlaced(payload)
    default:
        return fmt.Errorf("unknown message type: %s", msg.Type)
    }
}

json.RawMessage is also useful when you need to pass JSON through without modifying it — for proxies, caches, or forwarding event payloads.

Custom Marshaling

Go’s default JSON encoding doesn’t always match what APIs expect. Implement json.Marshaler and json.Unmarshaler to take full control:

// Custom date type that marshals as "2026-08-29" instead of RFC3339
type Date struct{ time.Time }

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

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

Another common case: enum-like constants that should serialize as strings:

type Status int

const (
    StatusPending Status = iota
    StatusActive
    StatusClosed
)

var statusNames = map[Status]string{
    StatusPending: "pending",
    StatusActive:  "active",
    StatusClosed:  "closed",
}

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(b []byte) error {
    var name string
    if err := json.Unmarshal(b, &name); err != nil {
        return err
    }
    for k, v := range statusNames {
        if v == name {
            *s = k
            return nil
        }
    }
    return fmt.Errorf("unknown status %q", name)
}

This produces "active" in JSON while the Go code works with typed integer constants.

Dynamic JSON with map[string]any

When the JSON structure is completely unknown, decode into map[string]any:

var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
    return err
}

// Access fields with type assertions
if name, ok := data["name"].(string); ok {
    fmt.Println("Name:", name)
}
// Numbers decode as float64 by default
if age, ok := data["age"].(float64); ok {
    fmt.Println("Age:", int(age))
}

Numbers in map[string]any decode as float64 by default. If you need integers without rounding risk, use json.Decoder with UseNumber(), which decodes numbers as json.Number (a string alias you can convert explicitly):

dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
var data map[string]any
dec.Decode(&data)
if age, err := data["age"].(json.Number).Int64(); err == nil {
    fmt.Println("Age:", age)
}

Performance Tips

For services that marshal/unmarshal JSON in every request, a few changes meaningfully reduce allocations:

Reuse buffers. json.Marshal allocates a new byte slice every call. For high-throughput paths, use json.Encoder with a sync.Pool-backed buffer:

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func marshalUser(u *User) ([]byte, error) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    if err := json.NewEncoder(buf).Encode(u); err != nil {
        return nil, err
    }
    return bytes.Clone(buf.Bytes()), nil
}

Consider faster libraries. encoding/json uses reflection and is not the fastest option. github.com/bytedance/sonic and github.com/json-iterator/go are drop-in replacements that are 2–4x faster on benchmarks. Profile first — the standard library is fast enough for most services.

Pre-allocate slices. If you’re marshaling a slice whose length you know, pre-allocate it rather than growing with append inside a loop. Fewer allocations = less GC pressure.

Summary

  • Always use struct tags — json:"field_name" for naming, omitempty for optional fields, json:"-" for sensitive/internal fields
  • Use json.Encoder/json.Decoder for HTTP handlers and file processing — avoids buffering the entire payload
  • Enable dec.DisallowUnknownFields() on request bodies to catch typos and protocol drift early
  • Use json.RawMessage to defer decoding polymorphic fields until you know the concrete type
  • Implement MarshalJSON/UnmarshalJSON for custom types — dates with non-standard formats, typed enums, embedded types
  • Numbers in map[string]any decode as float64; use dec.UseNumber() when integer precision matters

Resources

Comments

👍 Was this article helpful?