55. `encoding/json`

📖 Reading · 13 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

JSON is how Go programs talk to almost everything else. The standard library handles it with two functions and a struct tag, and once you understand the tag rules there's very little left to learn.

Marshal: struct → JSON

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name  string
    Email string
    Age   int
}

func main() {
    u := User{Name: "Ada", Email: "ada@example.com", Age: 36}

    data, err := json.Marshal(u)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println(string(data))

    pretty, _ := json.MarshalIndent(u, "", "  ")
    fmt.Println(string(pretty))
}

json.Marshal returns []byte, so string(data) to see it. MarshalIndent is the same thing formatted for humans — for logs and config files, not for the wire.

Note the field names came out capitalised: "Name", not "name". That's because only exported fields are marshalled at all, and by default the Go name is used verbatim. Which brings us to tags.

Struct tags

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Age      int    `json:"age,omitempty"`
    Nickname string `json:"-"`
}

func main() {
    full := User{Name: "Ada", Email: "ada@example.com", Age: 36, Nickname: "hidden"}
    empty := User{Name: "Grace", Email: "grace@example.com", Nickname: "also hidden"}

    a, _ := json.Marshal(full)
    b, _ := json.Marshal(empty)

    fmt.Println(string(a))
    fmt.Println(string(b))
}

A struct tag is the backtick string after a field. The three things you'll use:

  • json:"name" — the JSON key to use. Almost always needed, since JSON convention is lowercase and Go's is exported/capitalised.
  • json:",omitempty" — leave the field out entirely when it's the zero value. Notice age is absent from the second line.
  • json:"-" — never marshal this field. For passwords, internal state, anything that shouldn't leave the process.

Tags are just strings; the compiler doesn't check them. A typo like json"name" (missing colon) silently does nothing, which is why go vet checks tag syntax for you.

Unmarshal: JSON → struct

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func main() {
    data := []byte(`{"name":"Grace","email":"grace@example.com","age":45}`)

    var u User
    if err := json.Unmarshal(data, &u); err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("%+v\n", u)

    partial := []byte(`{"name":"Alan","unknown_field":true}`)
    var p User
    json.Unmarshal(partial, &p)
    fmt.Printf("%+v\n", p)

    broken := []byte(`{"name":`)
    err := json.Unmarshal(broken, &p)
    fmt.Println("bad json ->", err != nil)
}

Unmarshal takes a pointer&u — because it fills in the value you give it. Forgetting the & is the most common mistake here, and it produces a clear error rather than silent nonsense.

Two behaviours worth knowing:

  • Missing fields stay at their zero value. Alan has no email and Age: 0.
  • Unknown fields are ignored by default, which makes your decoder tolerant of API changes. (If you'd rather reject them, use a json.Decoder with DisallowUnknownFields.)

Matching is case-insensitive: "NAME" would still fill Name.

Nested structs and slices

package main

import (
    "encoding/json"
    "fmt"
)

type Address struct {
    City    string `json:"city"`
    Country string `json:"country"`
}

type Person struct {
    Name    string   `json:"name"`
    Tags    []string `json:"tags"`
    Address Address  `json:"address"`
}

func main() {
    p := Person{
        Name: "Ada",
        Tags: []string{"math", "computing"},
        Address: Address{City: "London", Country: "UK"},
    }

    out, _ := json.MarshalIndent([]Person{p}, "", "  ")
    fmt.Println(string(out))

    var back []Person
    if err := json.Unmarshal(out, &back); err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println(back[0].Address.City, back[0].Tags[1])
}

Nesting works exactly as you'd expect, in both directions, to any depth. A []Person becomes a JSON array; a map[string]int becomes a JSON object.

Decoding into map[string]any

When the shape isn't known ahead of time:

package main

import (
    "encoding/json"
    "fmt"
    "sort"
)

func main() {
    data := []byte(`{"name":"Ada","age":36,"active":true,"scores":[90,95]}`)

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

    keys := make([]string, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    sort.Strings(keys)

    for _, k := range keys {
        fmt.Printf("%-8s %-10T %v\n", k, m[k], m[k])
    }

    if age, ok := m["age"].(float64); ok {
        fmt.Println("age as an int:", int(age))
    }
}

Every JSON type maps to a fixed Go type here:

JSON Go, when decoding into any
number float64 — always, even for 36
string string
boolean bool
array []any
object map[string]any
null nil

That float64 catches everyone once. m["age"].(int) fails silently (ok is false); you must assert float64 and convert.

Prefer a struct whenever you know the shape. You get real types, no assertions, and the compiler on your side. map[string]any is for genuinely dynamic data.

Optional fields: pointers vs omitempty

package main

import (
    "encoding/json"
    "fmt"
)

type Settings struct {
    Theme    string `json:"theme"`
    Notify   bool   `json:"notify"`
    NotifyP  *bool  `json:"notify_ptr"`
}

func main() {
    data := []byte(`{"theme":"dark"}`)

    var s Settings
    json.Unmarshal(data, &s)

    fmt.Println("notify (plain bool):", s.Notify, "- but was it set? unknowable")
    fmt.Println("notify (pointer) is nil:", s.NotifyP == nil, "- definitely not set")

    data2 := []byte(`{"theme":"dark","notify_ptr":false}`)
    var s2 Settings
    json.Unmarshal(data2, &s2)
    fmt.Println("explicitly false:", s2.NotifyP != nil && !*s2.NotifyP)
}

This is the *bool case from the pointers module, in its natural habitat. If you must distinguish "the client sent false" from "the client said nothing", use a pointer field.

Custom marshalling

Implement MarshalJSON / UnmarshalJSON and your type controls its own representation — the same "implement an interface" trick as String():

type Money struct{ Cents int }

func (m Money) MarshalJSON() ([]byte, error) {
    return []byte(fmt.Sprintf(`"%d.%02d"`, m.Cents/100, m.Cents%100)), nil
}

func (m *Money) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    // parse "19.99" back into cents...
    return nil
}

Note the receivers: MarshalJSON on the value, UnmarshalJSON on the pointer (it has to modify). Getting that backwards means your method silently never runs — a genuinely hard bug to spot.

Streaming with Encoder and Decoder

For an io.Reader/io.Writer instead of a []byte — an HTTP body, a file, a network connection:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "strings"
)

type Event struct {
    ID   int    `json:"id"`
    Kind string `json:"kind"`
}

func main() {
    input := `{"id":1,"kind":"click"}
{"id":2,"kind":"scroll"}
{"id":3,"kind":"close"}`

    dec := json.NewDecoder(strings.NewReader(input))
    var events []Event
    for {
        var e Event
        if err := dec.Decode(&e); err != nil {
            break
        }
        events = append(events, e)
    }
    fmt.Println("decoded", len(events), "events")

    enc := json.NewEncoder(os.Stdout)
    enc.SetIndent("", "  ")
    enc.Encode(events[0])
}

The decoder reads one JSON value at a time, so it handles newline-delimited streams and huge files without loading everything into memory. In an HTTP handler you'd write json.NewDecoder(r.Body).Decode(&payload) — that's the line you'll type most often in a Go web service.

Your turn

Unmarshal the JSON into a Config struct with proper tags, then print the values:

localhost 8080 true
package main

import (
    "encoding/json"
    "fmt"
)

// define Config with json tags for host, port, debug

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

    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println(c.Host, c.Port, c.Debug)
}
package main

import (
    "encoding/json"
    "fmt"
)

type Config struct {
    Host  string `json:"host"`
    Port  int    `json:"port"`
    Debug bool   `json:"debug"`
}

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

    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println(c.Host, c.Port, c.Debug)
}

Next: putting things in order.