Overview

Go's encoding/json package is one of those things that feels clunky for about a week and then feels obvious. The friction comes from the same place Go's friction always comes from: the compiler wants to know the shape of your data, and JSON doesn't have shapes.

Here's how to work with it in practice.

Structs and tags

The normal case is that you know the shape and you write a struct for it:

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"`
    CreatedAt time.Time `json:"created_at"`
}

var u User
if err := json.Unmarshal(data, &u); err != nil {
    return err
}

A few things about tags that trip people up:

  • Without a tag, Go matches field names case-insensitively. Name matches "name", "Name", and "NAME". This is convenient right up until you have two fields that differ only in case.
  • omitempty drops the field on marshal when it's the zero value. For a string that means empty, for an int it means zero. So a field you intentionally set to 0 disappears. That surprises people writing APIs.
  • - as the tag name means "never touch this field." Use it for computed fields you don't want serialized.

The time.Time problem

time.Time marshals to RFC 3339 by default, which is fine. What isn't fine is that it requires a timezone offset on input. If your API sends "2026-09-18 14:30:00" — no T, no offset — unmarshalling fails.

Two options. Change the producer, which you should do if you control it. Or use a custom type:

type FlexibleTime struct {
    time.Time
}

func (t *FlexibleTime) UnmarshalJSON(data []byte) error {
    s := strings.Trim(string(data), `"`)
    if s == "" || s == "null" {
        return nil
    }
    parsed, err := time.Parse("2006-01-02 15:04:05", s)
    if err != nil {
        return err
    }
    t.Time = parsed
    return nil
}

I've written this type in three different codebases. It's a rite of passage.

When you don't know the shape

For webhooks, third-party APIs, and config files, you often can't declare a struct upfront. Use a map:

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

if eventType, ok := payload["type"].(string); ok {
    switch eventType {
    case "order.created":
        // ...
    }
}

The type assertion is the annoying part. Every value comes back as any, and you have to check. The one that catches everyone: all numbers become float64. An ID of 1234567890123456789 loses precision. If you're handling large integers, use json.Decoder with UseNumber():

dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()

var payload map[string]any
dec.Decode(&payload)

if n, ok := payload["id"].(json.Number); ok {
    id, err := n.Int64()
    // ...
}

This is the single most common source of "the ID got mangled" bugs in Go JSON handling.

Streaming with json.Decoder

json.Unmarshal reads the entire input into memory first. For a large response body or a file, decode the stream instead:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    return err
}

For arrays where each element should be handled as it arrives:

dec := json.NewDecoder(resp.Body)

// read the opening bracket
if _, err := dec.Token(); err != nil {
    return err
}

for dec.More() {
    var item Item
    if err := dec.Decode(&item); err != nil {
        return err
    }
    process(item)
}

This keeps memory flat regardless of how large the response is, which matters when you're consuming a paginated export of a million records.

Custom marshalling

If a type needs a non-standard representation, implement the interfaces:

type Money struct {
    Cents int64
    Currency string
}

func (m Money) MarshalJSON() ([]byte, error) {
    return json.Marshal(map[string]any{
        "amount":   float64(m.Cents) / 100,
        "currency": m.Currency,
    })
}

func (m *Money) UnmarshalJSON(data []byte) error {
    var raw struct {
        Amount   float64 `json:"amount"`
        Currency string  `json:"currency"`
    }
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    m.Cents = int64(math.Round(raw.Amount * 100))
    m.Currency = raw.Currency
    return nil
}

Watch the pointer receiver on UnmarshalJSON. If you define it on the value receiver, json.Unmarshal won't find it and you'll get silent, confusing behavior.

Mistakes I keep making

MistakeWhat happens
Unexported struct fieldsSilently skipped, field stays zero
Missing & in json.Unmarshal(data, u)Compile error, which is the good case
Numbers into anyBecome float64, precision loss on large ints
Ignoring the error from EncodeHalf-written response bodies
Assuming field order on marshalStructs serialize in declaration order, maps alphabetically, and neither is guaranteed by the spec

Is encoding/json fast enough?

For most services, yes. If profiling says JSON is your bottleneck, the usual replacements are github.com/json-iterator/go (drop-in, API-compatible) or github.com/goccy/go-json. Both are meaningfully faster on large payloads. But measure first — I've seen teams swap libraries to fix a problem that was actually a database query.