55. `encoding/json`
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. Noticeageis 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.
Alanhas no email andAge: 0. - Unknown fields are ignored by default, which makes your decoder
tolerant of API changes. (If you'd rather reject them, use a
json.DecoderwithDisallowUnknownFields.)
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.