3 min read
On this page

Encoding & JSON

The encoding/json package is one of the most used packages in Go. It converts between Go structs and JSON with struct tags controlling the mapping. The same patterns apply to encoding/xml, encoding/csv, and other encoding packages. Understanding JSON encoding well prepares you for all of them.

Marshal & Unmarshal

json.Marshal converts a Go value to JSON bytes. json.Unmarshal does the reverse.

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    // Struct to JSON
    u := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
    data, err := json.Marshal(u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(data))

    // JSON to struct
    jsonStr := `{"id":2,"name":"Bob","email":"bob@example.com"}`
    var u2 User
    err = json.Unmarshal([]byte(jsonStr), &u2)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%+v\n", u2)
}
{"id":1,"name":"Alice","email":"alice@example.com"}
{ID:2 Name:Bob Email:bob@example.com}

Only exported fields (uppercase) are included in JSON output. Unexported fields are silently ignored.

Struct Tags

Struct tags control the JSON field names and behavior.

type Product struct {
    ID          int     `json:"id"`
    Name        string  `json:"name"`
    Price       float64 `json:"price"`
    Description string  `json:"description,omitempty"`
    InternalSKU string  `json:"-"`
}

The tag options:

  • json:"name" — sets the JSON field name
  • json:"name,omitempty" — omits the field if it has a zero value (empty string, 0, nil, false, empty slice/map)
  • json:"-" — always omits the field
  • json:",string" — encodes a number or bool as a JSON string
func main() {
    p := Product{
        ID:          42,
        Name:        "Widget",
        Price:       9.99,
        Description: "", // zero value — will be omitted
        InternalSKU: "WDG-042",
    }
    data, _ := json.MarshalIndent(p, "", "  ")
    fmt.Println(string(data))
}
{
  "id": 42,
  "name": "Widget",
  "price": 9.99
}

The Description field is omitted because it is empty and tagged with omitempty. The InternalSKU field is omitted because it is tagged with -.

Custom MarshalJSON & UnmarshalJSON

When the default encoding does not fit, implement the json.Marshaler or json.Unmarshaler interface.

type Timestamp struct {
    time.Time
}

func (t Timestamp) MarshalJSON() ([]byte, error) {
    formatted := t.Time.Format("2006-01-02T15:04:05Z")
    return json.Marshal(formatted)
}

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

type Event struct {
    Name      string    `json:"name"`
    CreatedAt Timestamp `json:"created_at"`
}

This gives you full control over how a type appears in JSON.

Streaming with json.Decoder & json.Encoder

For reading JSON from an io.Reader or writing to an io.Writer, use json.Decoder and json.Encoder instead of Marshal/Unmarshal.

func DecodeFromHTTP(resp *http.Response, v any) error {
    defer resp.Body.Close()
    return json.NewDecoder(resp.Body).Decode(v)
}

func EncodeToHTTP(w http.ResponseWriter, v any) error {
    w.Header().Set("Content-Type", "application/json")
    return json.NewEncoder(w).Encode(v)
}

Streaming is more memory-efficient for large payloads because it does not buffer the entire JSON document in memory.

Decoding a Stream of JSON Objects

json.Decoder can read multiple JSON values from a single stream.

func DecodeStream(r io.Reader) ([]Event, error) {
    dec := json.NewDecoder(r)
    var events []Event

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

    for dec.More() {
        var e Event
        if err := dec.Decode(&e); err != nil {
            return nil, err
        }
        events = append(events, e)
    }

    return events, nil
}

The json.RawMessage Trick

json.RawMessage delays JSON decoding. It stores raw JSON bytes, letting you inspect a field before deciding how to decode the rest.

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

type TextPayload struct {
    Text string `json:"text"`
}

type ImagePayload struct {
    URL    string `json:"url"`
    Width  int    `json:"width"`
    Height int    `json:"height"`
}

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

    switch msg.Type {
    case "text":
        var p TextPayload
        if err := json.Unmarshal(msg.Payload, &p); err != nil {
            return err
        }
        fmt.Println("Text:", p.Text)
    case "image":
        var p ImagePayload
        if err := json.Unmarshal(msg.Payload, &p); err != nil {
            return err
        }
        fmt.Printf("Image: %s (%dx%d)\n", p.URL, p.Width, p.Height)
    default:
        return fmt.Errorf("unknown message type: %s", msg.Type)
    }
    return nil
}
func main() {
    textJSON := `{"type":"text","payload":{"text":"hello"}}`
    HandleMessage([]byte(textJSON))

    imageJSON := `{"type":"image","payload":{"url":"pic.png","width":800,"height":600}}`
    HandleMessage([]byte(imageJSON))
}
Text: hello
Image: pic.png (800x600)

This pattern is essential for APIs that use polymorphic JSON — where the structure of one field depends on the value of another.

Working with APIs: Decode the Response Body

A common pattern for consuming JSON APIs in Go.

type APIResponse struct {
    Data  []User `json:"data"`
    Total int    `json:"total"`
    Page  int    `json:"page"`
}

func FetchUsers(baseURL string, page int) ([]User, error) {
    url := fmt.Sprintf("%s/api/users?page=%d", baseURL, page)
    resp, err := http.Get(url)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, body)
    }

    var apiResp APIResponse
    if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
        return nil, fmt.Errorf("decode failed: %w", err)
    }

    return apiResp.Data, nil
}

Other Encoding Packages

The same patterns apply to other encoding packages in the standard library.

encoding/xml

type Feed struct {
    XMLName xml.Name `xml:"feed"`
    Title   string   `xml:"title"`
    Entries []Entry  `xml:"entry"`
}

type Entry struct {
    Title   string `xml:"title"`
    Link    string `xml:"link,attr"`
    Summary string `xml:"summary"`
}

encoding/csv

func ReadCSV(r io.Reader) ([][]string, error) {
    reader := csv.NewReader(r)
    return reader.ReadAll()
}

func WriteCSV(w io.Writer, records [][]string) error {
    writer := csv.NewWriter(w)
    for _, record := range records {
        if err := writer.Write(record); err != nil {
            return err
        }
    }
    writer.Flush()
    return writer.Error()
}

Common Pitfalls

  • Unexported fields are ignored. Only fields starting with an uppercase letter are marshaled/unmarshaled. This is the most common source of "my JSON is empty" bugs.
  • Forgetting to close the response body. Always defer resp.Body.Close() after an HTTP call. Failing to close leaks connections.
  • Decoding into a nil pointer. json.Unmarshal needs a pointer to an allocated value. Passing a nil pointer causes a runtime error.
  • omitempty with zero values. The omitempty tag omits zero values, which includes 0 for numbers and false for bools. If zero is a valid value in your domain, do not use omitempty — or use a pointer type so nil is the zero value instead.
  • Number precision. JSON numbers are decoded as float64 by default when unmarshaling into any. For large integers, use json.Decoder with UseNumber() to preserve precision.
  • Not handling unknown fields. By default, unknown JSON fields are silently ignored during unmarshaling. Use json.Decoder with DisallowUnknownFields() for strict parsing.
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
err := dec.Decode(&v)

Key Takeaways

  • json.Marshal and json.Unmarshal handle conversion between Go values and JSON bytes.
  • Struct tags (json:"name,omitempty") control field names and serialization behavior.
  • Implement MarshalJSON/UnmarshalJSON for custom encoding logic.
  • Use json.Decoder/json.Encoder for streaming JSON with io.Reader/io.Writer.
  • json.RawMessage enables flexible schemas where decoding depends on a discriminator field.
  • The same struct tag and streaming patterns apply to encoding/xml, encoding/csv, and other encoding packages.
  • Always check for errors and close response bodies when working with APIs.