Skip to content
Back to Learn
·6 min read

JSON in Go: Structs, Encoding, and Best Practices

Go's encoding/json package provides powerful JSON encoding and decoding through struct tags, interfaces, and streaming APIs. Go's approach to JSON is unique — it uses compile-time type safety, struct field tags for mapping, and explicit error handling. This guide covers everything from basic struct mapping to advanced custom serialization, performance optimization, and best practices. Use our JSON to Go generator to create structs from JSON samples and JSON Validator to verify output.

Basic Struct Mapping

import "encoding/json"

type Address struct {
    Street string
    City   string
    Zip    string
}

type User struct {
    ID      int
    Name    string
    Email   string
    Age     int
    Address Address
    Tags    []string
}

// JSON to struct
jsonString := "{"id":1,"name":"Alice","email":"alice@example.com","age":30}"
var user User
err := json.Unmarshal([]byte(jsonString), &user)
if err != nil {
    log.Fatal(err)
}

// Struct to JSON
bytes, err := json.Marshal(user)
// Compact: {"ID":1,"Name":"Alice","Email":"alice@example.com","Age":30,"Address":{"Street":"","City":"","Zip":""},"Tags":null}

// Pretty print
bytes, err = json.MarshalIndent(user, "", "  ")

Struct Tags (JSON Field Mapping)

type User struct {
    ID        int       // json:"id"
    Name      string    // json:"name"
    Email     string    // json:"email"
    Password  string    // json:"-" - skipped entirely
    Age       int       // json:"age,omitempty"
    CreatedAt time.Time // json:"created_at"
    Metadata  map[string]interface{} // json:"metadata,omitempty"
}

// Tag options:
// json:fieldname         - Use custom field name in JSON
// json:-                - Always exclude from JSON
// json:fieldname,omitempty  - Exclude if zero value
// json:,string          - Force string encoding for numbers
// json:,omitempty       - Exclude if zero value (no rename)

// Example with string option
type Product struct {
    ID    int     // json:"id,string" - encode as "42" not 42
    Price float64 // json:"price,string"
}

product := Product{ID: 42, Price: 19.99}
bytes, _ := json.Marshal(product)
// {"id":"42","price":"19.99"}

Custom JSON Marshal/Unmarshal

// Custom time format
type CustomTime struct {
    time.Time
}

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

func (ct *CustomTime) 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
    }
    ct.Time = t
    return nil
}

// Using custom marshal/unmarshal
type Event struct {
    ID   int        // json:"id"
    Date CustomTime // json:"date"
}

event := Event{ID: 1, Date: CustomTime{time.Now()}}
bytes, _ := json.Marshal(event)
// {"id":1,"date":"2025-01-15"}

JSON RawMessage for Dynamic Fields

// json.RawMessage preserves raw JSON for deferred decoding
type Response struct {
    Status string          // json:"status"
    Data   json.RawMessage // json:"data" - raw, unparsed JSON
}

func processResponse(body []byte) error {
    var resp Response
    if err := json.Unmarshal(body, &resp); err != nil {
        return err
    }

    // Decide how to parse Data based on Status
    switch resp.Status {
    case "user":
        var user User
        json.Unmarshal(resp.Data, &user)
        // Process user
    case "product":
        var product Product
        json.Unmarshal(resp.Data, &product)
        // Process product
    }
    return nil
}

Streaming JSON with Decoder/Encoder

// Streaming decoder for NDJSON or large JSON arrays
func streamUsers(reader io.Reader) error {
    dec := json.NewDecoder(reader)

    // Expect opening bracket for array
    t, err := dec.Token()
    if err != nil {
        return err
    }
    if delim, ok := t.(json.Delim); !ok || delim != '[' {
        return fmt.Errorf("expected array start")
    }

    // Decode each array element
    for dec.More() {
        var user User
        if err := dec.Decode(&user); err != nil {
            return err
        }
        processUser(user)
    }

    // Expect closing bracket
    _, err = dec.Token()
    return err
}

// Streaming encoder for NDJSON output
func writeUsers(writer io.Writer, users []User) error {
    enc := json.NewEncoder(writer)
    for _, user := range users {
        if err := enc.Encode(user); err != nil {
            return err
        }
    }
    return nil
}

JSON with Interfaces and Type Switches

// Unmarshal into interface{} for dynamic data
func processDynamicJSON(data []byte) {
    var result interface{}
    json.Unmarshal(data, &result)

    // Type switch to handle different structures
    switch v := result.(type) {
    case map[string]interface{}:
        for key, val := range v {
            fmt.Printf("Key: %s, Value: %v (type: %T)
", key, val, val)
        }
    case []interface{}:
        for i, item := range v {
            fmt.Printf("Index: %d, Value: %v
", i, item)
        }
    }
}

// Use json.Number to preserve number precision
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber() // Numbers become json.Number (string) instead of float64

var result map[string]interface{}
dec.Decode(&result)

// Access numbers without precision loss
age := result["age"].(json.Number)
ageInt, _ := age.Int64() // Parse as int64
ageFloat, _ := age.Float64() // Parse as float64

Performance Optimization

TechniqueImprovementCode
Pre-allocate slices50-80% faster for large arraysusers := make([]User, 0, expectedSize)
Use bytes.BufferFaster than strings.Builder for JSONbuf := new(bytes.Buffer)
Avoid reflect with fast-pathUse concrete types, not interface{}Map to specific struct types
Use json.Encoder for streamsNo intermediate byte slicejson.NewEncoder(w).Encode(v)
Disable HTML escapingSlightly faster, smaller outputenc.SetEscapeHTML(false)

Common Pitfalls

  • Unexported fields are silently ignored during marshaling
  • JSON null unmarshals to Go's zero value, not nil pointer
  • float64 is the default for JSON numbers in interface{} - use json.Number or UseNumber()
  • Time marshaling uses RFC3339 by default
  • Channel, complex, and function types cannot be marshaled
  • Cyclic struct references cause infinite recursion

Next Steps

Generate Go structs from your JSON with JSON to Go. Validate JSON output with JSON Validator. Format Go JSON output with JSON Formatter.