Skip to content

JSON and XML Encoding/Decoding Intermediate

Introduction

JSON is the lingua franca of web APIs, and Go's encoding/json package makes it straightforward to convert between Go structs and JSON. You'll use json.Marshal/Unmarshal for in-memory conversion and json.Encoder/Decoder for streaming I/O. Struct tags control field naming, omission, and behavior. For XML, encoding/xml follows the same patterns with additional tag syntax for attributes and nesting.

Mastering JSON encoding is essential — virtually every Go web service reads and writes JSON.

Syntax & Usage

json.Marshal — Go to JSON

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"`
    CreatedAt time.Time `json:"created_at"`
    password  string    // unexported — not included in JSON
}

user := User{
    ID:        1,
    Name:      "Alice",
    CreatedAt: time.Now(),
}

data, err := json.Marshal(user)
if err != nil {
    return err
}
fmt.Println(string(data))
// {"id":1,"name":"Alice","created_at":"2025-01-15T10:30:00Z"}
// Note: Email omitted because it's empty and has `omitempty`

Pretty-printed output:

data, err := json.MarshalIndent(user, "", "  ")

json.Unmarshal — JSON to Go

jsonStr := `{"id": 1, "name": "Alice", "email": "alice@example.com"}`

var user User
if err := json.Unmarshal([]byte(jsonStr), &user); err != nil {
    return fmt.Errorf("parsing user JSON: %w", err)
}
fmt.Printf("%+v\n", user) // {ID:1 Name:Alice Email:alice@example.com ...}

Struct Tags

Struct tags control how fields map to JSON keys:

type Product struct {
    ID          int      `json:"id"`                  // renamed to "id"
    Name        string   `json:"name"`                // renamed to "name"
    Price       float64  `json:"price"`               // renamed to "price"
    Description string   `json:"description,omitempty"` // omit if empty string
    InternalRef string   `json:"-"`                   // always excluded
    Category    string   `json:",omitempty"`           // keep field name, omit if empty
    Count       int      `json:"count,string"`        // encode as JSON string "42"
}
Tag Effect
json:"name" Use "name" as JSON key
json:",omitempty" Omit field if zero value
json:"-" Always exclude from JSON
json:"name,omitempty" Rename and omit if empty
json:",string" Encode number/bool as JSON string

What counts as a zero value for omitempty?

false, 0, "", nil pointer, nil interface, empty array/slice/map. Note: a zero-value struct is not omitted — this is a common surprise.

json.Encoder / json.Decoder — Streaming

For HTTP handlers and file I/O, use streaming instead of Marshal/Unmarshal:

// HTTP handler: decode request, encode response
func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }

    user := processRequest(req)

    w.Header().Set("Content-Type", "application/json")
    if err := json.NewEncoder(w).Encode(user); err != nil {
        log.Printf("encoding response: %v", err)
    }
}

Reading JSON from a file:

f, err := os.Open("config.json")
if err != nil {
    return err
}
defer f.Close()

var config Config
if err := json.NewDecoder(f).Decode(&config); err != nil {
    return fmt.Errorf("decoding config: %w", err)
}

Custom MarshalJSON / UnmarshalJSON

Implement the json.Marshaler and json.Unmarshaler interfaces for custom serialization:

type Status int

const (
    StatusActive Status = iota
    StatusInactive
    StatusBanned
)

func (s Status) MarshalJSON() ([]byte, error) {
    names := map[Status]string{
        StatusActive:   "active",
        StatusInactive: "inactive",
        StatusBanned:   "banned",
    }
    name, ok := names[s]
    if !ok {
        return nil, fmt.Errorf("unknown status: %d", s)
    }
    return json.Marshal(name) // returns `"active"` (with quotes)
}

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

Handling Unknown Fields

By default, json.Unmarshal silently ignores unknown fields. To reject them:

decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
    http.Error(w, "unknown field in request", http.StatusBadRequest)
    return
}

json.RawMessage — Lazy/Partial Parsing

Defer parsing of a JSON field until you know its type:

type Event struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"` // raw JSON, parsed later
}

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

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

Encoding Maps and Slices

// Map → JSON object
data, _ := json.Marshal(map[string]int{
    "alice": 95,
    "bob":   87,
})
// {"alice":95,"bob":87}

// Slice → JSON array
data, _ := json.Marshal([]string{"go", "rust", "python"})
// ["go","rust","python"]

// Decoding into a generic structure
var result map[string]any
json.Unmarshal(jsonBytes, &result)
// Numbers become float64, arrays become []any, objects become map[string]any

time.Time in JSON

time.Time marshals to RFC 3339 by default ("2025-01-15T10:30:00Z"). For custom formats:

type CustomTime struct {
    time.Time
}

const layout = "2006-01-02" // Go's reference time format

func (ct CustomTime) MarshalJSON() ([]byte, error) {
    return json.Marshal(ct.Format(layout))
}

func (ct *CustomTime) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    t, err := time.Parse(layout, s)
    if err != nil {
        return err
    }
    ct.Time = t
    return nil
}

XML Encoding (Brief)

encoding/xml follows the same Marshal/Unmarshal pattern with XML-specific struct tags:

type Person struct {
    XMLName xml.Name `xml:"person"`
    Name    string   `xml:"name"`
    Age     int      `xml:"age,attr"`        // XML attribute
    Address string   `xml:"contact>address"` // nested element
}

p := Person{Name: "Alice", Age: 30, Address: "123 Main St"}
data, err := xml.MarshalIndent(p, "", "  ")

Output:

<person age="30">
  <name>Alice</name>
  <contact>
    <address>123 Main St</address>
  </contact>
</person>

Common Pattern: API Response Structs

type APIResponse[T any] struct {
    Data    T      `json:"data,omitempty"`
    Error   string `json:"error,omitempty"`
    Status  int    `json:"status"`
}

func respondJSON[T any](w http.ResponseWriter, status int, data T) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse[T]{
        Data:   data,
        Status: status,
    })
}

func respondError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse[any]{
        Error:  msg,
        Status: status,
    })
}

Common Pattern: Config File Parsing

type Config struct {
    Server   ServerConfig   `json:"server"`
    Database DatabaseConfig `json:"database"`
    LogLevel string         `json:"log_level"`
}

type ServerConfig struct {
    Host string `json:"host"`
    Port int    `json:"port"`
}

type DatabaseConfig struct {
    DSN             string `json:"dsn"`
    MaxOpenConns    int    `json:"max_open_conns"`
    ConnMaxLifetime string `json:"conn_max_lifetime"`
}

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }

    return &cfg, nil
}

Quick Reference

Operation Function Notes
Go → JSON bytes json.Marshal(v) Returns []byte, error
Go → JSON (pretty) json.MarshalIndent(v, "", " ") Indented output
JSON bytes → Go json.Unmarshal(data, &v) Pass pointer to target
Stream encode json.NewEncoder(w).Encode(v) Writes to io.Writer
Stream decode json.NewDecoder(r).Decode(&v) Reads from io.Reader
Rename field json:"name" Struct tag
Omit empty json:",omitempty" Skip zero values
Exclude field json:"-" Never marshal
Lazy parse json.RawMessage Deferred unmarshal
Custom format MarshalJSON() / UnmarshalJSON() Implement interfaces
Reject unknown decoder.DisallowUnknownFields() Strict parsing

Best Practices

  1. Always use struct tags — without tags, JSON keys match the Go field name exactly (capitalized), which is non-standard for JSON APIs.
  2. Use omitempty thoughtfully — it's great for optional fields but can hide bugs when zero values are meaningful (e.g., count: 0).
  3. Use json.Decoder for HTTP bodies — it's more memory-efficient than reading the body into []byte and calling Unmarshal.
  4. Set Content-Type: application/json — always set the header when writing JSON responses.
  5. Define separate request/response structs — don't reuse the same struct for database models and API responses. This prevents accidentally exposing internal fields.
  6. Use json.RawMessage for polymorphic JSON — parse the discriminator field first, then decode the payload into the correct type.
  7. Use pointer fields for nullable JSON*string distinguishes between null/missing (nil) and "" (empty string).

Common Pitfalls

Unexported fields are invisible to JSON

type User struct {
    Name  string `json:"name"`
    email string `json:"email"` // unexported — silently ignored!
}
JSON encoding only sees exported (capitalized) fields. This is the #1 mistake beginners make.

omitempty doesn't work on structs

type Response struct {
    Data  DataStruct `json:"data,omitempty"`
}
// A zero-value DataStruct{} is NOT omitted — it serializes as {"data": {}}

// FIX: use a pointer
type Response struct {
    Data *DataStruct `json:"data,omitempty"`
}
// Now nil pointer IS omitted

Numbers decode as float64 into any/interface{}

var result map[string]any
json.Unmarshal([]byte(`{"count": 42}`), &result)
count := result["count"] // type is float64, not int!

// To get an int
n := int(result["count"].(float64))

// Or use json.Decoder with UseNumber
dec := json.NewDecoder(r)
dec.UseNumber()
// Numbers become json.Number (a string), parsed explicitly

Marshaling nil slice vs empty slice

var nilSlice []string
emptySlice := []string{}

json.Marshal(nilSlice)  // null
json.Marshal(emptySlice) // []
APIs typically expect [], not null. Initialize slices explicitly if the distinction matters.

Forgetting to handle Decode errors in HTTP handlers

// BUG: if Decode fails, req is zero-valued — processing garbage
json.NewDecoder(r.Body).Decode(&req)
processRequest(req)

// FIX: always check the error
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    http.Error(w, "invalid JSON", http.StatusBadRequest)
    return
}

Performance Considerations

  • json.Marshal vs json.Encoder: Marshal creates a []byte in memory. Encoder writes directly to an io.Writer. For HTTP responses, Encoder avoids an intermediate buffer allocation.
  • Struct tag parsing is cached: The encoding/json package caches the reflection metadata per type. The first marshal/unmarshal of a type is slower; subsequent calls are fast.
  • Large JSON payloads: For very large JSON documents, use json.Decoder with Token() to stream-parse without loading the entire document into memory.
  • Alternative libraries: encoding/json uses reflection, which has overhead. For hot paths, consider code-generated alternatives like easyjson or sonic that can be 5-10x faster. Profile first.
  • json.RawMessage avoids double parsing: When you only need part of a JSON document, use RawMessage for fields you want to parse later (or not at all).

Interview Tips

Interview Tip

"How do you handle JSON in Go?" Use json.Marshal/Unmarshal for in-memory conversion and json.Encoder/Decoder for streaming I/O. Struct tags (json:"name,omitempty") control field naming and behavior. Only exported fields are included. Implement MarshalJSON/UnmarshalJSON for custom serialization.

Interview Tip

"What happens if a JSON field doesn't match any struct field?" By default, it's silently ignored. Use decoder.DisallowUnknownFields() to reject unknown fields. Conversely, struct fields without a matching JSON key get their zero value.

Interview Tip

"How do you handle polymorphic JSON?" Use json.RawMessage to defer parsing. Parse a discriminator field (like "type") first, then unmarshal the raw payload into the correct Go type based on the discriminator value. This is the standard pattern for event systems and APIs with multiple response shapes.

Interview Tip

"What's the difference between a nil slice and an empty slice in JSON?" A nil slice marshals to null, while an initialized empty slice marshals to []. Most APIs expect [] for empty arrays, so initialize your slices explicitly: items := []string{} or items := make([]string, 0).

Key Takeaways

  • json.Marshal/Unmarshal convert between Go values and JSON bytes; Encoder/Decoder stream to/from io.Writer/io.Reader.
  • Struct tags (json:"name,omitempty") are essential — they control JSON key names, omission, and exclusion.
  • Only exported fields are visible to the JSON encoder.
  • Use json.RawMessage for polymorphic JSON and lazy parsing.
  • Implement MarshalJSON/UnmarshalJSON for custom serialization (enums, dates, etc.).
  • Nil slices marshal to null, empty slices to [] — be intentional about initialization.
  • Use json.Decoder for HTTP request bodies — it's memory-efficient and supports strict mode.