05 · JSON Encoding/Decoding¶
Why encoding/json matters¶
JSON is the lingua franca of web APIs, config files and log pipelines. Go's
encoding/json package converts between Go values and JSON text using
reflection, driven by struct tags — no code generation, no schema files. Get
comfortable here and you can consume any REST API (which is exactly what
Module 8 and the
Level 2 project do).
Two directions, two verbs:
- Marshal — Go value → JSON bytes (also called encoding, serialising).
- Unmarshal — JSON bytes → Go value (decoding, deserialising).
Marshalling: Go to JSON¶
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string
Email string
Age int
admin bool // unexported -- INVISIBLE to encoding/json
}
func main() {
u := User{Name: "Ada", Email: "ada@example.com", Age: 36, admin: true}
data, err := json.Marshal(u)
if err != nil {
fmt.Println("marshal error:", err)
return
}
fmt.Println(string(data))
// {"Name":"Ada","Email":"ada@example.com","Age":36}
// MarshalIndent for human-readable output.
pretty, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(pretty))
// {
// "Name": "Ada",
// "Email": "ada@example.com",
// "Age": 36
// }
}
Two rules to lock in right now:
- Only exported fields are encoded.
adminis lowercase, soencoding/jsoncannot see it — no error, it just silently disappears. This is the single most common JSON bug in Go. Marshalreturns[]byte, sostring(data)is needed to print it.
Struct tags: controlling the JSON shape¶
Go field names are capitalised; JSON keys usually are not. Struct tags bridge the gap:
package main
import (
"encoding/json"
"fmt"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Description string `json:"description,omitempty"` // dropped when ""
Tags []string `json:"tags,omitempty"` // dropped when nil/empty
Internal string `json:"-"` // never encoded or decoded
StockCount int `json:"stock,string"` // encoded as a JSON string
}
func main() {
p := Product{ID: 1, Name: "Mug", Price: 9.99, Internal: "secret", StockCount: 42}
data, _ := json.MarshalIndent(p, "", " ")
fmt.Println(string(data))
// {
// "id": 1,
// "name": "Mug",
// "price": 9.99,
// "stock": "42"
// }
}
| Tag | Effect |
|---|---|
json:"name" |
use name as the JSON key |
json:"name,omitempty" |
omit when the field is the zero value |
json:"-" |
never encode or decode this field |
json:"-," |
use the literal key - |
json:",omitempty" |
keep the Go field name, but allow omission |
json:"n,string" |
encode a number/bool as a JSON string |
Watch out: omitempty treats 0, false and "" as empty. A Quantity int
with omitempty will vanish when it is legitimately zero. Use *int when you
need to distinguish "zero" from "absent".
Unmarshalling: JSON to Go¶
Unmarshal needs a pointer so it has something to write into:
package main
import (
"encoding/json"
"fmt"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func main() {
input := `{"id": 7, "name": "Notebook", "price": 3.50, "colour": "blue"}`
var p Product
if err := json.Unmarshal([]byte(input), &p); err != nil { // note the &
fmt.Println("unmarshal error:", err)
return
}
fmt.Printf("%+v\n", p)
// {ID:7 Name:Notebook Price:3.5}
}
Note what did not happen: the unknown key colour was silently ignored,
and no error was raised. Field matching is case-insensitive, so "NAME",
"name" and "Name" all fill Name — the exact tag match wins if there is
one.
Missing JSON keys leave the Go field at its zero value, again with no error. If absence must be detectable, use a pointer:
Nested structs and slices¶
Real API payloads nest. Model them with nested types:
package main
import (
"encoding/json"
"fmt"
)
type Address struct {
City string `json:"city"`
Country string `json:"country"`
}
type Employee struct {
Name string `json:"name"`
Address Address `json:"address"`
Skills []string `json:"skills"`
}
type Company struct {
Name string `json:"name"`
Employees []Employee `json:"employees"`
}
func main() {
input := `{
"name": "Acme",
"employees": [
{"name": "Ada", "address": {"city": "London", "country": "UK"},
"skills": ["go", "math"]},
{"name": "Grace", "address": {"city": "Boston", "country": "US"},
"skills": ["compilers"]}
]
}`
var c Company
if err := json.Unmarshal([]byte(input), &c); err != nil {
fmt.Println("error:", err)
return
}
for _, e := range c.Employees {
fmt.Printf("%s (%s, %s) knows %v\n",
e.Name, e.Address.City, e.Address.Country, e.Skills)
}
// Ada (London, UK) knows [go math]
// Grace (Boston, US) knows [compilers]
}
You only need to declare the fields you actually care about — everything else in the payload is skipped. For a large API response, model the three fields you use, not all forty.
Dynamic JSON with maps and any¶
When the shape genuinely is not known ahead of time, decode into
map[string]any. Values arrive as a small fixed set of Go types:
| JSON | Go type in any |
|---|---|
| object | map[string]any |
| array | []any |
| string | string |
| number | float64 (always — even integers) |
true/false |
bool |
null |
nil |
package main
import (
"encoding/json"
"fmt"
)
func main() {
input := `{"name": "Ada", "age": 36, "active": true, "scores": [1, 2, 3]}`
var m map[string]any
if err := json.Unmarshal([]byte(input), &m); err != nil {
fmt.Println("error:", err)
return
}
// Every value needs a type assertion (see Module 1).
name := m["name"].(string)
age := m["age"].(float64) // NOT int -- JSON numbers decode to float64
fmt.Printf("%s is %d\n", name, int(age)) // Ada is 36
for k, v := range m {
fmt.Printf("%s: %v (%T)\n", k, v, v)
}
}
The float64 rule catches everyone: m["age"].(int) panics. Prefer a struct
whenever you know the shape — you get real types and compile-time safety.
Streaming with Encoder and Decoder¶
json.Marshal builds the entire document in memory. For files, HTTP bodies
and anything large, use the streaming API, which works on any io.Reader or
io.Writer:
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
)
type Event struct {
Type string `json:"type"`
User string `json:"user"`
}
func main() {
// Decoder: read a stream of JSON values.
stream := `{"type":"login","user":"ada"}
{"type":"logout","user":"grace"}`
dec := json.NewDecoder(strings.NewReader(stream))
dec.DisallowUnknownFields() // strict mode: error on unexpected keys
for {
var e Event
if err := dec.Decode(&e); err != nil {
break // io.EOF ends the loop
}
fmt.Printf("%s by %s\n", e.Type, e.User)
}
// login by ada
// logout by grace
// Encoder: write straight to a Writer (stdout, a file, an HTTP response).
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(Event{Type: "ping", User: "system"})
}
DisallowUnknownFields() flips the default silence into a hard error — very
useful for config files where a typo'd key should not be quietly ignored.
Custom marshalling¶
Implement json.Marshaler/json.Unmarshaler when a type needs a
non-default representation — a timestamp in a specific layout, for example:
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type Date struct{ time.Time }
const dateLayout = "2006-01-02"
func (d Date) MarshalJSON() ([]byte, error) {
return []byte(`"` + d.Format(dateLayout) + `"`), nil
}
func (d *Date) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
t, err := time.Parse(dateLayout, s)
if err != nil {
return fmt.Errorf("parsing date %q: %w", s, err)
}
d.Time = t
return nil
}
type Task struct {
Title string `json:"title"`
Due Date `json:"due"`
}
func main() {
var t Task
_ = json.Unmarshal([]byte(`{"title":"Ship it","due":"2026-03-15"}`), &t)
fmt.Println(t.Title, t.Due.Year()) // Ship it 2026
out, _ := json.Marshal(t)
fmt.Println(string(out)) // {"title":"Ship it","due":"2026-03-15"}
}
Note the receivers: MarshalJSON on the value, UnmarshalJSON on the pointer
(it must modify the receiver) — exactly the method-set rules from
Module 3.
How It Actually Works¶
encoding/json builds its behavior at runtime through reflection: the first time it
marshals a given type, it inspects the type's fields with reflect.Type, reads
struct tags via StructTag.Get("json"), and caches a slice of field-encoder
functions keyed by that type so repeat calls skip the inspection step — this cache
(sync.Map internally) is why marshaling the same struct type repeatedly is faster
after the first call. Unmarshaling into a struct walks the JSON token stream with a
hand-written recursive-descent-style scanner and, for each JSON object key, does a
case-insensitive field-name match against the cached field list — which is exactly
why Go silently ignores JSON fields with no matching struct field, and why struct
tags exist at all: they let you decouple your Go field names from the wire format's
key names without either side reflecting on the other's naming convention. Because
this is all reflection-driven rather than compiled-in, encoding/json is
meaningfully slower than a code-generated marshaler (like easyjson or the newer
encoding/json/v2 proposal) that emits direct field-access code the compiler can
inline.
Cheat sheet¶
| Task | Syntax |
|---|---|
| Struct → JSON | data, err := json.Marshal(v) |
| Pretty JSON | json.MarshalIndent(v, "", " ") |
| JSON → struct | json.Unmarshal(data, &v) |
| Read from a stream | json.NewDecoder(r).Decode(&v) |
| Write to a stream | json.NewEncoder(w).Encode(v) |
| Reject unknown keys | dec.DisallowUnknownFields() |
| Rename a key | `json:"user_id"` |
| Omit zero values | `json:"bio,omitempty"` |
| Skip a field | `json:"-"` |
| Unknown shape | var m map[string]any |
| Validate JSON text | json.Valid(data) |
Related lessons¶
- Structs and tags: Level 1, Module 6.
- Type assertions for
map[string]any: Module 1. - Reading and writing JSON files: Module 7.
- Decoding API responses over the wire: Module 8.
Exercise¶
Define a Book struct with Title, Author, Year, ISBN and
Tags []string, tagged so it encodes as title, author, year, isbn and
tags, with isbn and tags omitted when empty. Marshal a slice of three
books to indented JSON and print it. Then unmarshal a JSON array string
(including one book with an unknown extra key and one missing year) back into
[]Book and print each one, confirming the unknown key is ignored and the
missing year becomes 0. Finally, re-decode the same input with a
json.Decoder using DisallowUnknownFields() and print the resulting error.