Nested & Composite JSON
You already know Marshal, Unmarshal, and struct tags. That's enough for a
flat record like {"name": "Ada", "email": "ada@example.com"}. Real APIs
rarely stop there — documents nest, arrays hold objects, and some keys are
just... missing. This lesson covers the shapes you'll actually meet.
Structs inside structs
encoding/json decodes nested objects by matching shape, recursively. If a
field's type is itself a struct, Go unmarshals the nested object into it —
no extra code required:
type Address struct {
City string `json:"city"`
Country string `json:"country"`
}
type Person struct {
Name string `json:"name"`
Address Address `json:"address"`
}
Given {"name":"Ada","address":{"city":"London","country":"UK"}},
Unmarshal walks into "address", sees Person.Address is a struct, and
recurses using that struct's own tags. You never manually parse the inner
object — it composes for free, at any depth.
Arrays of objects
A JSON array of objects maps to a slice of structs, same idea:
type Order struct {
Items []Item `json:"items"`
}
Each element of the "items" array is unmarshaled against Item's tags in
turn. An empty array ([]) produces a non-nil, zero-length slice; a missing
"items" key leaves the slice nil. Both have len() == 0, so if you need
to distinguish "explicitly empty" from "not sent," check for nil rather
than length.
Maps for open-ended keys
Sometimes you don't know the keys in advance — a set of feature flags, a bag of scores keyed by username. A struct can't model that; a map can:
var scores map[string]int
json.Unmarshal([]byte(`{"ada": 97, "bo": 88}`), &scores)
Use a struct when the keys are fixed and known (that's most of your data).
Reach for map[string]T only when the keys themselves are variable data —
mixing the two approaches for the same field is a sign the schema needs a
rethink.
Pointers for "maybe absent"
A plain string field can't tell you "the key was absent" from "the key
was present with an empty value" — both decode to "". A pointer field
can, because Unmarshal only allocates it when the key is present:
type Profile struct {
Bio *string `json:"bio"`
}
{"bio": ""} gives you a non-nil pointer to "". No "bio" key at all
gives you nil. That distinction matters whenever "not provided" and
"provided as empty" mean different things to your program — a PATCH
request that should leave a field untouched when it's absent, versus clear
it when it's sent as "", is the classic case. Dereference carefully:
check for nil before reading *p, or you'll panic on the fields that
really were left out.
Fields that must never leave
omitempty controls what Marshal skips when empty, but the field can
still be set and can still round-trip through Unmarshal from untrusted
input. For a field that must never appear in JSON at all — a password
hash, an internal-only ID — tag it json:"-". That single hyphen tells
encoding/json to skip the field entirely, both directions: Marshal
never writes it, and Unmarshal never populates it, no matter what the
input document contains. As a footnote: if you genuinely need a field to
map to a JSON key that is literally -, escape it as json:"-," — the
trailing comma disambiguates.
Putting it together
An order in a shop system might look like this:
type Order struct {
ID string `json:"id"`
Customer Customer `json:"customer"`
Items []Item `json:"items"`
Note *string `json:"note"`
Internal string `json:"-"`
}
Customer nests a struct, Items nests a slice of structs, Note uses a
pointer because a note is genuinely optional, and Internal never crosses
the boundary in either direction. That's exactly the shape you'll build in
the exercise — parse it, then compute a total from the nested items.
Practice
1. Order Total
IntermediateReal JSON is nested: objects inside objects, arrays of objects, optional fields. The struct types are given — your job is the two functions.
ParseOrder(data []byte) (Order, error)— unmarshaldatainto anOrder. On failure return the zeroOrderand the error.(o Order) Total() int— the sum ofQuantity × Priceover all items, in cents.
Notice the field tags while you're in there:
Note *stringis nil when the key is absent (that's how you tell "missing" from "empty"), andInternal stringis taggedjson:"-"so it never appears in JSON — in either direction. The hidden tests check both.package main import ( "fmt" ) // Customer is the person who placed the order. type Customer struct { Name string `json:"name"` Email string `json:"email"` } // Item is one line of an order. Price is in cents — ints don't accumulate // floating-point rounding errors. type Item struct { SKU string `json:"sku"` Quantity int `json:"quantity"` Price int `json:"price_cents"` } // Order is a full customer order. Note is optional: a missing "note" key // leaves it nil. Internal must never cross the JSON boundary. type Order struct { ID string `json:"id"` Customer Customer `json:"customer"` Items []Item `json:"items"` Note *string `json:"note"` Internal string `json:"-"` } // ParseOrder unmarshals data into an Order. func ParseOrder(data []byte) (Order, error) { var o Order // TODO: unmarshal data into &o; return Order{} and the error on failure return o, nil } // Total returns the order total in cents. func (o Order) Total() int { // TODO: sum Quantity × Price over o.Items return 0 } func main() { data := []byte(`{"id":"A-100","customer":{"name":"Ada","email":"ada@example.com"},"items":[{"sku":"tea","quantity":2,"price_cents":350}]}`) o, err := ParseOrder(data) if err != nil { fmt.Println("parse error:", err) return } fmt.Printf("order %s: %d cents\n", o.ID, o.Total()) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.