Encoding Json #

JSON is the most common data exchange format in modern APIs — there’s almost no Go application that interacts with the outside world without touching JSON. The encoding/json package provides everything needed: converting Go structs to JSON (Marshal), loading JSON into structs (Unmarshal), streaming JSON to and from io.Reader/io.Writer, and mechanisms for customizing encoding and decoding behavior. What makes encoding/json interesting is its reflection-based approach — you define a struct with json: tags and the package handles all the conversion automatically. But there are many important details to understand: handling nil vs empty values, omitted fields, unmarshalable types, subtle decoding errors, and when streaming is better than buffered marshaling.

An Overview of the encoding/json Package #

flowchart LR
    subgraph Encode["Struct → JSON"]
        E1["json.Marshal(v)\nstruct → []byte"]
        E2["json.MarshalIndent(v, '', '  ')\nstruct → []byte (pretty)"]
        E3["json.NewEncoder(w).Encode(v)\nstruct → io.Writer (streaming)"]
    end

    subgraph Decode["JSON → Struct"]
        D1["json.Unmarshal(data, &v)\n[]byte → struct"]
        D2["json.NewDecoder(r).Decode(&v)\nio.Reader → struct (streaming)"]
    end

    subgraph Tags["Struct Tags"]
        T1["json:"field_name""]
        T2["json:"name,omitempty""]
        T3["json:"-""]
        T4["json:",string""]
    end

    subgraph Custom["Customization"]
        C1["json.Marshaler interface\nMarshalJSON() ([]byte, error)"]
        C2["json.Unmarshaler interface\nUnmarshalJSON([]byte) error"]
        C3["json.RawMessage\nraw JSON without parsing"]
    end

    Encode --> JSON["JSON"]
    JSON --> Decode
    Tags --> Encode
    Tags --> Decode
    Custom --> Encode
    Custom --> Decode

    style Encode fill:#e8f5e9
    style Decode fill:#e3f2fd
    style Tags fill:#fff3e0
    style Custom fill:#f3e5f5

Marshal — Struct to JSON #

json.Marshal converts a Go value into a []byte containing JSON. It uses reflection to read struct fields and the json: tags to determine the field names in the output.

package main

import (
    "encoding/json"
    "fmt"
)

type Product struct {
    ID       int     `json:"id"`
    Name     string  `json:"name"`
    Price    float64 `json:"price"`
    InStock  bool    `json:"in_stock"`
}

func main() {
    p := Product{
        ID:      1,
        Name:    "Laptop Go Edition",
        Price:   15000000,
        InStock: true,
    }

    // Marshal into a []byte
    data, err := json.Marshal(p)
    if err != nil {
        fmt.Println("marshal error:", err)
        return
    }
    fmt.Println(string(data))
    // {"id":1,"name":"Laptop Go Edition","price":1.5e+07,"in_stock":true}

    // MarshalIndent — for human-readable output
    dataIndent, err := json.MarshalIndent(p, "", "  ")
    if err != nil {
        fmt.Println("marshal error:", err)
        return
    }
    fmt.Println(string(dataIndent))
    // {
    //   "id": 1,
    //   "name": "Laptop Go Edition",
    //   "price": 1.5e+07,
    //   "in_stock": true
    // }
}

What Can and Can’t Be Marshaled #

flowchart TD
    V["Go value"] --> Can{"Can it\nbe marshaled?"}

    Can -- Yes --> Y1["struct — becomes a JSON object"]
    Can -- Yes --> Y2["map[string]T — becomes a JSON object"]
    Can -- Yes --> Y3["[]T, [N]T — becomes a JSON array"]
    Can -- Yes --> Y4["string — becomes a JSON string"]
    Can -- Yes --> Y5["int, float — becomes a JSON number"]
    Can -- Yes --> Y6["bool — becomes true/false"]
    Can -- Yes --> Y7["nil pointer — becomes null"]
    Can -- Yes --> Y8["time.Time — becomes an RFC3339 string"]

    Can -- Error --> N1["channel — can't"]
    Can -- Error --> N2["func — can't"]
    Can -- Error --> N3["complex — can't"]
    Can -- Error --> N4["maps with non-string\nnon-int keys"]

    style Y1 fill:#e8f5e9
    style Y2 fill:#e8f5e9
    style Y3 fill:#e8f5e9
    style Y4 fill:#e8f5e9
    style Y5 fill:#e8f5e9
    style Y6 fill:#e8f5e9
    style Y7 fill:#e8f5e9
    style Y8 fill:#e8f5e9
    style N1 fill:#fce4ec
    style N2 fill:#fce4ec
    style N3 fill:#fce4ec
    style N4 fill:#fff3e0
// Marshalable types
m := map[string]int{"a": 1, "b": 2}
data, _ := json.Marshal(m)
fmt.Println(string(data)) // {"a":1,"b":2}

slice := []string{"one", "two", "three"}
data, _ = json.Marshal(slice)
fmt.Println(string(data)) // ["one","two","three"]

// nil — becomes null
var p *Product = nil
data, _ = json.Marshal(p)
fmt.Println(string(data)) // null

// interface{} / any — marshal the value inside
var v any = map[string]any{
    "name":  "Budi",
    "age":   30,
    "active": true,
}
data, _ = json.Marshal(v)
fmt.Println(string(data)) // {"active":true,"age":30,"name":"Budi"}

// Types that CAN'T be marshaled
ch := make(chan int)
_, err := json.Marshal(ch)
fmt.Println(err) // json: unsupported type: chan int

Struct Tags — Controlling Serialization #

The json: struct tag is the main mechanism for controlling how struct fields are represented in JSON.

type User struct {
    // Different field names between Go and JSON
    ID       int    `json:"id"`
    FullName string `json:"full_name"`

    // omitempty — omit the field if it's the zero value
    Phone    string   `json:"phone,omitempty"`  // omitted if ""
    Age      int      `json:"age,omitempty"`    // omitted if 0
    Active   bool     `json:"active,omitempty"` // omitted if false
    Score    float64  `json:"score,omitempty"`  // omitted if 0.0
    Tags     []string `json:"tags,omitempty"`   // omitted if nil or []

    // - (minus) — always omit this field from JSON
    Password string `json:"-"`
    Token    string `json:"-"`

    // ,string — encode/decode numbers as JSON strings
    // useful for JavaScript, which can't handle int64 precisely
    ExternalID int64 `json:"external_id,string"`

    // No tag — the Go field name is used as-is
    Note string // → "Note" in JSON

    // Embedded struct — its fields "rise" to the top level
    Address
}

type Address struct {
    City     string `json:"city"`
    Province string `json:"province"`
}

// Example output
p := User{
    ID:         1,
    FullName:   "Budi Santoso",
    Phone:      "",      // omitempty — doesn't appear
    Age:        0,       // omitempty — doesn't appear
    ExternalID: 12345678901234,
    Password:   "secret", // json:"-" — doesn't appear
    Address: Address{
        City:     "Jakarta",
        Province: "DKI Jakarta",
    },
}

data, _ := json.MarshalIndent(p, "", "  ")
fmt.Println(string(data))
// {
//   "id": 1,
//   "full_name": "Budi Santoso",
//   "external_id": "12345678901234",  ← number as a string
//   "Note": "",
//   "city": "Jakarta",               ← from the embedded Address
//   "province": "DKI Jakarta"
// }

Pointers to Distinguish Zero Value from Absent #

omitempty can’t distinguish between false deliberately set and false because it wasn’t filled in. Pointers solve this problem:

// ANTI-PATTERN: can't distinguish "active=false" vs "not set"
type BadConfig struct {
    Active bool `json:"active,omitempty"`
    // If Active=false, this field disappears from the JSON
    // even though false could mean "deliberately disabled"!
}

// CORRECT: use a pointer for values that can be intentionally null
type GoodConfig struct {
    Active *bool `json:"active,omitempty"`
    // nil → omitted from the JSON (not set)
    // &false → appears as false (deliberately disabled)
    // &true → appears as true (deliberately enabled)
}

// Helper for creating pointers to literals
func boolPtr(b bool) *bool { return &b }
func intPtr(n int) *int    { return &n }

cfg := GoodConfig{
    Active: boolPtr(false), // appears as "active": false
}
data, _ := json.Marshal(cfg)
fmt.Println(string(data)) // {"active":false}

cfg2 := GoodConfig{
    Active: nil, // doesn't appear at all
}
data, _ = json.Marshal(cfg2)
fmt.Println(string(data)) // {}

Unmarshal — JSON to Struct #

json.Unmarshal loads JSON from a []byte into a struct. Fields missing from the JSON are left at their zero values, and JSON fields not in the struct are ignored.

type Article struct {
    ID          int      `json:"id"`
    Title       string   `json:"title"`
    Content     string   `json:"content"`
    Tags        []string `json:"tags"`
    Published   bool     `json:"published"`
}

jsonData := []byte(`{
    "id": 42,
    "title": "Learning Go",
    "content": "Go is a fun language",
    "tags": ["go", "programming", "tutorial"],
    "published": true,
    "unknown_field": "ignored"
}`)

var article Article
err := json.Unmarshal(jsonData, &article)
if err != nil {
    fmt.Println("unmarshal error:", err)
    return
}

fmt.Println(article.ID)        // 42
fmt.Println(article.Title)     // Learning Go
fmt.Println(article.Tags)      // [go programming tutorial]
fmt.Println(article.Published) // true

Unmarshaling into a map — Dynamic Structures #

When the JSON structure isn’t known in advance, unmarshal into a map[string]any:

jsonData := []byte(`{
    "name": "Budi",
    "age": 30,
    "active": true,
    "score": 98.5,
    "tags": ["admin", "user"]
}`)

var result map[string]any
err := json.Unmarshal(jsonData, &result)
if err != nil {
    fmt.Println("error:", err)
    return
}

// Access values with type assertions
name := result["name"].(string)
age := result["age"].(float64) // NOTE: all JSON numbers → float64!
active := result["active"].(bool)

fmt.Printf("Name: %s, Age: %.0f, Active: %v\n", name, age, active)

// Iterate all fields
for key, val := range result {
    fmt.Printf("%s (%T): %v\n", key, val, val)
}
When unmarshaling into a map[string]any or interface{}, all JSON numbers are converted to float64, not int. This is the default behavior of the encoding/json package. To get numbers as int, use json.Number with Decoder.UseNumber(), or unmarshal into a struct with the right types.

Handling Unknown Fields #

// Detect unknown fields in JSON input
type Config struct {
    Host string `json:"host"`
    Port int    `json:"port"`
}

jsonData := []byte(`{"host":"localhost","port":8080,"debug":true}`)

// DisallowUnknownFields — errors if there's an unknown field
decoder := json.NewDecoder(strings.NewReader(string(jsonData)))
decoder.DisallowUnknownFields()

var cfg Config
if err := decoder.Decode(&cfg); err != nil {
    fmt.Println(err)
    // json: unknown field "debug"
}

Encoder and Decoder — Streaming JSON #

json.NewEncoder and json.NewDecoder work directly with io.Writer and io.Reader — more efficient than Marshal/Unmarshal because they don’t need to buffer the entire data in memory.

flowchart LR
    subgraph Buffered["Buffered (Marshal/Unmarshal)"]
        B1["Struct"] --> B2["json.Marshal"] --> B3["[]byte\n(entire data in memory)"] --> B4["io.Writer"]
        B5["io.Reader"] --> B6["read everything\ninto []byte"] --> B7["json.Unmarshal"] --> B8["Struct"]
    end

    subgraph Stream["Streaming (Encoder/Decoder)"]
        S1["Struct"] --> S2["Encoder.Encode"] --> S3["io.Writer\n(write directly)"]
        S4["io.Reader"] --> S5["Decoder.Decode"] --> S6["Struct\n(read per token)"]
    end

    subgraph When["Use Streaming If"]
        W1["HTTP response — ResponseWriter is an io.Writer"]
        W2["Large JSON files — don't fit in memory"]
        W3["JSON Lines — many objects in one stream"]
        W4["Request body — Body is an io.Reader"]
    end

    style Buffered fill:#fff3e0
    style Stream fill:#e8f5e9
    style When fill:#e3f2fd

Encoder — Writing JSON to an io.Writer #

import (
    "encoding/json"
    "net/http"
    "os"
)

// Writing JSON to an HTTP response (most common)
func productHandler(w http.ResponseWriter, r *http.Request) {
    product := Product{ID: 1, Name: "Laptop", Price: 15000000}

    w.Header().Set("Content-Type", "application/json")
    // CORRECT: use an Encoder directly to the ResponseWriter
    if err := json.NewEncoder(w).Encode(product); err != nil {
        // If the error happens here, the header is already sent — can't change the status code
        // Just log it
        fmt.Fprintf(os.Stderr, "encode error: %v\n", err)
    }
}

// Writing JSON to a file
func saveToFile(path string, data any) error {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("saveToFile: %w", err)
    }
    defer f.Close()

    encoder := json.NewEncoder(f)
    encoder.SetIndent("", "  ") // pretty print
    if err := encoder.Encode(data); err != nil {
        return fmt.Errorf("saveToFile encode: %w", err)
    }
    return nil
}

// Writing many objects as JSON Lines (NDJSON)
// Format: one JSON object per line, useful for logs and streams
func writeJSONLines(w io.Writer, items []Product) error {
    encoder := json.NewEncoder(w)
    for _, item := range items {
        if err := encoder.Encode(item); err != nil {
            return fmt.Errorf("writeJSONLines: %w", err)
        }
        // Encode automatically adds a newline after every object
    }
    return nil
}

Decoder — Reading JSON from an io.Reader #

// Reading JSON from an HTTP request body
func createProductHandler(w http.ResponseWriter, r *http.Request) {
    var product Product

    // CORRECT: use a Decoder directly from the Body
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // optional: reject unknown fields

    if err := decoder.Decode(&product); err != nil {
        http.Error(w, "invalid request body: "+err.Error(),
            http.StatusBadRequest)
        return
    }

    // Validate after decoding
    if product.Name == "" {
        http.Error(w, "product name must not be empty", http.StatusBadRequest)
        return
    }

    // Process the product...
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(product)
}

// Reading JSON Lines from a file — one object per line
func readJSONLines(path string) ([]Product, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("readJSONLines: %w", err)
    }
    defer f.Close()

    var products []Product
    decoder := json.NewDecoder(f)

    for decoder.More() { // More() returns true if there's more data
        var p Product
        if err := decoder.Decode(&p); err != nil {
            return nil, fmt.Errorf("readJSONLines decode: %w", err)
        }
        products = append(products, p)
    }

    return products, nil
}

json.RawMessage — Raw JSON #

json.RawMessage is a []byte implementing json.Marshaler and json.Unmarshaler — useful for deferring the parsing of part of a JSON or passing JSON through unchanged.

// Pattern 1: a field whose content is free-form JSON (schema unknown)
type EventLog struct {
    EventType string          `json:"type"`
    Time      time.Time       `json:"time"`
    Data      json.RawMessage `json:"data"` // any JSON
}

// Marshal: Data stays as JSON as-is
log := EventLog{
    EventType: "purchase",
    Time:      time.Now(),
    Data:      json.RawMessage(`{"product_id":42,"quantity":2,"total":30000}`),
}
data, _ := json.Marshal(log)
fmt.Println(string(data))
// {"type":"purchase","time":"...","data":{"product_id":42,"quantity":2,"total":30000}}

// Unmarshal: Data isn't parsed, stored as-is
var logRead EventLog
json.Unmarshal(data, &logRead)
fmt.Println(string(logRead.Data))
// {"product_id":42,"quantity":2,"total":30000}

// Pattern 2: a discriminated union — parse based on the type field
type Message struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

type ChatPayload struct {
    Text   string `json:"text"`
    Sender string `json:"sender"`
}

type ImagePayload struct {
    URL    string `json:"url"`
    Size   int    `json:"size"`
}

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

    switch msg.Type {
    case "chat":
        var p ChatPayload
        if err := json.Unmarshal(msg.Payload, &p); err != nil {
            return nil, err
        }
        return p, nil
    case "image":
        var p ImagePayload
        if err := json.Unmarshal(msg.Payload, &p); err != nil {
            return nil, err
        }
        return p, nil
    default:
        return nil, fmt.Errorf("unknown message type: %s", msg.Type)
    }
}

Custom Marshalers and Unmarshalers #

To fully control how a type is converted to and from JSON, implement the json.Marshaler and json.Unmarshaler interfaces.

sequenceDiagram
    participant App as Application code
    participant Enc as json.Marshal
    participant Type as Type with MarshalJSON

    App->>Enc: json.Marshal(value)
    Enc->>Enc: Check whether the type\nimplements json.Marshaler
    Enc->>Type: MarshalJSON()
    Type-->>Enc: custom JSON []byte
    Enc-->>App: final JSON

    Note over Enc,Type: Same for Unmarshal → UnmarshalJSON([]byte)
// Example 1: time.Time with a custom format (not the default RFC3339)
type Date struct {
    time.Time
}

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

func (d *Date) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    parsed, err := time.Parse("2006-01-02", s)
    if err != nil {
        return fmt.Errorf("Date.UnmarshalJSON: format must be YYYY-MM-DD: %w", err)
    }
    d.Time = parsed
    return nil
}

type Order struct {
    ID        int     `json:"id"`
    CreatedOn Date    `json:"created_on"`
    Total     float64 `json:"total"`
}

// Output: {"id":1,"created_on":"2024-03-15","total":150000}
// not: {"id":1,"created_on":"2024-03-15T00:00:00Z","total":150000}
// Example 2: enums / constants as strings
type OrderStatus int

const (
    StatusPending OrderStatus = iota
    StatusProcessing
    StatusShipped
    StatusCompleted
    StatusCancelled
)

var statusNames = map[OrderStatus]string{
    StatusPending:    "pending",
    StatusProcessing: "processing",
    StatusShipped:    "shipped",
    StatusCompleted:  "completed",
    StatusCancelled:  "cancelled",
}

var nameStatuses = map[string]OrderStatus{
    "pending":    StatusPending,
    "processing": StatusProcessing,
    "shipped":    StatusShipped,
    "completed":  StatusCompleted,
    "cancelled":  StatusCancelled,
}

func (s OrderStatus) MarshalJSON() ([]byte, error) {
    name, ok := statusNames[s]
    if !ok {
        return nil, fmt.Errorf("OrderStatus.MarshalJSON: invalid value: %d", s)
    }
    return json.Marshal(name)
}

func (s *OrderStatus) UnmarshalJSON(data []byte) error {
    var name string
    if err := json.Unmarshal(data, &name); err != nil {
        return err
    }
    status, ok := nameStatuses[name]
    if !ok {
        return fmt.Errorf("OrderStatus.UnmarshalJSON: invalid value: %q", name)
    }
    *s = status
    return nil
}

type Order2 struct {
    ID     int         `json:"id"`
    Status OrderStatus `json:"status"`
}

p := Order2{ID: 1, Status: StatusShipped}
data, _ := json.Marshal(p)
fmt.Println(string(data)) // {"id":1,"status":"shipped"}

Handling JSON Errors Correctly #

Errors from json.Unmarshal can be of various types — understanding them helps provide more informative error messages to users.

import (
    "encoding/json"
    "errors"
)

func decodeRequest(body io.Reader, target any) error {
    decoder := json.NewDecoder(body)
    decoder.DisallowUnknownFields()

    if err := decoder.Decode(target); err != nil {
        var syntaxErr *json.SyntaxError
        var typeErr *json.UnmarshalTypeError

        switch {
        case errors.As(err, &syntaxErr):
            return fmt.Errorf("invalid JSON at position %d: %w",
                syntaxErr.Offset, err)

        case errors.As(err, &typeErr):
            return fmt.Errorf("type mismatch: field %q expected %v, got %v",
                typeErr.Field, typeErr.Type, typeErr.Value)

        case errors.Is(err, io.EOF):
            return fmt.Errorf("request body is empty")

        case errors.Is(err, io.ErrUnexpectedEOF):
            return fmt.Errorf("incomplete JSON")

        default:
            return fmt.Errorf("failed to decode request: %w", err)
        }
    }
    return nil
}

// Usage in a handler
func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := decodeRequest(r.Body, &user); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // continue...
}

Production Usage Patterns #

Consistent API Responses #

// A uniform API response structure
type APIResponse struct {
    Success bool   `json:"success"`
    Data    any    `json:"data,omitempty"`
    Message string `json:"message,omitempty"`
    Error   string `json:"error,omitempty"`
}

func sendJSON(w http.ResponseWriter, statusCode int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        fmt.Fprintf(os.Stderr, "sendJSON encode error: %v\n", err)
    }
}

func sendSuccess(w http.ResponseWriter, data any) {
    sendJSON(w, http.StatusOK, APIResponse{
        Success: true,
        Data:    data,
    })
}

func sendError(w http.ResponseWriter, statusCode int, message string) {
    sendJSON(w, statusCode, APIResponse{
        Success: false,
        Error:   message,
    })
}

// Usage in a handler
func listProductsHandler(w http.ResponseWriter, r *http.Request) {
    products, err := listProductsService()
    if err != nil {
        sendError(w, http.StatusInternalServerError, "failed to load products")
        return
    }
    sendSuccess(w, products)
}

Configuration from a JSON File #

type AppConfig struct {
    Server   ServerConfig   `json:"server"`
    Database DatabaseConfig `json:"database"`
    Log      LogConfig      `json:"log"`
}

type ServerConfig struct {
    Host         string        `json:"host"`
    Port         int           `json:"port"`
    ReadTimeout  time.Duration `json:"read_timeout"`
    WriteTimeout time.Duration `json:"write_timeout"`
}

type DatabaseConfig struct {
    DSN         string `json:"dsn"`
    MaxOpenConn int    `json:"max_open_conn"`
    MaxIdleConn int    `json:"max_idle_conn"`
}

type LogConfig struct {
    Level  string `json:"level"`
    Format string `json:"format"`
}

func loadConfig(path string) (*AppConfig, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("loadConfig: %w", err)
    }
    defer f.Close()

    // Default values before decoding
    cfg := &AppConfig{
        Server: ServerConfig{
            Host:         "0.0.0.0",
            Port:         8080,
            ReadTimeout:  15 * time.Second,
            WriteTimeout: 15 * time.Second,
        },
        Log: LogConfig{
            Level:  "info",
            Format: "json",
        },
    }

    decoder := json.NewDecoder(f)
    decoder.DisallowUnknownFields()

    if err := decoder.Decode(cfg); err != nil {
        return nil, fmt.Errorf("loadConfig decode: %w", err)
    }

    return cfg, nil
}

Transforming JSON Without Structs #

// Rename a JSON field without a full struct
func renameJSONField(input []byte, old, new string) ([]byte, error) {
    var data map[string]json.RawMessage
    if err := json.Unmarshal(input, &data); err != nil {
        return nil, err
    }

    if val, exists := data[old]; exists {
        data[new] = val
        delete(data, old)
    }

    return json.Marshal(data)
}

// Filter sensitive fields before sending to the client
type PublicUser struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    // Password, Token, etc. are not here
}

func filterUser(u *User) PublicUser {
    return PublicUser{ID: u.ID, Name: u.FullName}
}

Streaming Many Objects #

// Export thousands of products to JSON Lines without buffering everything in memory
func exportProducts(w io.Writer, repo ProductRepository) error {
    encoder := json.NewEncoder(w)

    // Iterate from the database with a cursor, not loading everything at once
    cursor, err := repo.Cursor()
    if err != nil {
        return fmt.Errorf("exportProducts: open cursor: %w", err)
    }
    defer cursor.Close()

    count := 0
    for cursor.Next() {
        product, err := cursor.Scan()
        if err != nil {
            return fmt.Errorf("exportProducts: scan row %d: %w", count, err)
        }

        if err := encoder.Encode(product); err != nil {
            return fmt.Errorf("exportProducts: encode row %d: %w", count, err)
        }
        count++
    }

    fmt.Fprintf(os.Stderr, "Export done: %d products\n", count)
    return cursor.Err()
}

When to Switch to Alternatives #

Keep using encoding/json if:
  ✓ Marshaling and unmarshaling structs to/from JSON
  ✓ Streaming JSON to HTTP responses or from request bodies
  ✓ Simple configuration from JSON files
  ✓ Internal APIs between Go services

Consider encoding/json with json.Number if:
  ✗ Unmarshaling large numbers that can't be represented by float64
  ✗ You need to know the original number type (int vs float) from unstructured JSON

Consider external libraries if:
  ✗ Performance is very critical (thousands of marshals per second) → sonic, go-json, jsoniter
    (can be 3-10x faster than encoding/json for certain cases)
  ✗ JSON Schema validation → gojsonschema or a validator library
  ✗ JSONPath queries → gjson for field access without full unmarshaling
  ✗ JSON Patch / JSON Merge Patch → RFC 6902 implementations
  ✗ YAML with JSON conversion → gopkg.in/yaml.v3

Summary #

  • Struct tags json:"name" control the field names in JSON — use snake_case for compatibility with common JSON conventions.
  • omitempty omits the field if its value is the zero value ("", 0, false, nil) — use pointers (*bool, *int) if you need to distinguish “not set” from “set to zero”.
  • json:"-" always omits the field from JSON — use it for Password, Token, and other sensitive fields.
  • All JSON numbers become float64 when unmarshaling into interface{} or map[string]any — use decoder.UseNumber() or unmarshal into a struct with the right types if precision matters.
  • Use json.NewEncoder(w).Encode(v) instead of json.Marshal for HTTP responses — more efficient because it doesn’t buffer all the data in memory.
  • Use json.NewDecoder(r.Body).Decode(&v) instead of json.Unmarshal for request bodies — streams directly from the body without reading everything into a []byte first.
  • json.RawMessage lets you store and pass through raw JSON without parsing — useful for discriminated unions or fields with dynamic schemas.
  • Custom MarshalJSON/UnmarshalJSON give full control over the format — use them for enums, custom date formats, or representations that can’t be expressed with tags alone.
  • decoder.DisallowUnknownFields() helps detect typos or unsupported fields in request bodies — useful for strict APIs.
  • errors.As with *json.SyntaxError and *json.UnmarshalTypeError enables specific error messages about what’s wrong in the JSON input.

← Previous: Errors   Next: Net Http →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact