How to Handle JSON with Nested Objects in Go

Unmarshal nested JSON in Go by defining a struct with sub-struct fields and using json.Unmarshal.

The nested JSON problem

You are building a client for a weather API. The response looks clean in your browser: a top-level object with a location string, a current temperature, and a nested forecast array containing daily objects. Each daily object has its own conditions and precipitation fields. You copy the JSON, paste it into a Go file, and stare at the screen. How do you turn that tree of braces and colons into something your program can actually read? You do not parse it character by character. You do not write a custom state machine. You define a Go struct that mirrors the shape of the data, and you hand the byte slice to the standard library.

How Go maps JSON to structs

Go treats JSON as a tree of values. The encoding/json package walks that tree and matches each node to a field in your struct. It uses reflection to inspect your types at runtime. Reflection means the program looks at its own structure while it runs. The cost is a small performance hit, but the tradeoff buys you type safety and zero boilerplate. When the unmarshaler sees a JSON object, it looks for a struct or a map. When it sees a JSON array, it looks for a slice or an array. When it sees a string, number, or boolean, it matches the corresponding Go type.

The mapping follows strict rules. JSON keys are strings. Go struct fields are capitalized to be exported. The unmarshaler ignores unexported fields entirely. To tell the unmarshaler which JSON key belongs to which Go field, you use struct tags. A struct tag is a string literal attached to a field definition. It looks like a comment but the compiler treats it as metadata. The json:"key" tag is the most common one. It says "when reading or writing JSON, use this exact string as the field name."

Under the hood, json.Unmarshal calls reflect.TypeOf on your target value. It builds a mapping of JSON keys to struct field indices. When it encounters a nested object, it recursively descends into the child struct. This recursive descent happens at runtime, which is why unmarshaling large payloads takes longer than reading flat arrays. The reflection machinery allocates temporary interface values and performs type assertions. For most applications, the overhead is negligible. For high-throughput systems processing millions of events per second, you will eventually switch to code generation tools like easyjson or ffjson. For everything else, the standard library is fast enough.

The minimal working example

Here is the simplest nested struct setup. You define a parent type and a child type, attach the tags, and pass a pointer to the parent into json.Unmarshal.

package main

import (
	"encoding/json"
	"fmt"
)

// Location holds geographic data for a single point.
type Location struct {
	Lat float64 `json:"lat"`
	Lon float64 `json:"lon"`
}

// Place represents a named location with coordinates.
type Place struct {
	Name    string   `json:"name"`
	Coords  Location `json:"coordinates"`
	Visited bool     `json:"visited"`
}

func main() {
	// Raw JSON payload from an external service.
	data := []byte(`{"name":"Kyoto","coordinates":{"lat":35.0116,"lon":135.7681},"visited":true}`)

	var p Place
	// Unmarshal walks the JSON tree and fills the struct fields.
	// It requires a pointer so it can modify the struct in place.
	if err := json.Unmarshal(data, &p); err != nil {
		panic(err)
	}

	fmt.Println(p.Name, p.Coords.Lat)
}

Walking through the unmarshal process

The program starts by declaring two types. Location holds two floating point numbers. Place holds a string, a Location value, and a boolean. The struct tags map coordinates to the Coords field. Notice the capital letters on the Go side and the lowercase on the JSON side. Go requires exported fields for the encoding/json package to access them. If you lowercase Coords, the unmarshaler silently skips it and leaves the field at its zero value.

Inside main, the JSON string is converted to a byte slice. json.Unmarshal takes that slice and a pointer to p. It must be a pointer because the function needs to modify the struct in place. The unmarshaler reads the first key, name, matches it to the Name field via the tag, and writes the string. It moves to coordinates, sees a nested object, and recursively unmarshals it into the Coords field. The recursion stops when it hits primitive types. If the JSON contains extra keys that do not match any struct field, the unmarshaler ignores them. If a struct field has no matching key in the JSON, it stays at its zero value.

The community accepts the if err != nil boilerplate because it forces you to handle failure paths instead of hiding them behind exceptions. Wrap the error with fmt.Errorf("decode failed: %w", err) if you need to add context before returning it up the call stack. Run gofmt on save. It aligns your struct tags and keeps the indentation consistent across the team. Do not argue about formatting. Let the tool decide.

Real-world API response handling

Real APIs rarely return perfectly clean data. They include metadata, pagination, and optional nested objects. You will often need to handle missing fields gracefully and stream large responses instead of loading everything into memory at once. json.Decoder reads from an io.Reader, which makes it suitable for HTTP responses. It also reports errors at the exact byte offset where parsing failed.

Here is a handler that decodes a paginated API response with nested items. It uses a decoder instead of unmarshaling the whole body upfront.

package main

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

// Item represents a single record in the response.
type Item struct {
	ID  int    `json:"id"`
	Tag string `json:"tag"`
	Meta struct {
		Created string `json:"created_at"`
		Score   int    `json:"score"`
	} `json:"meta"`
}

// Response wraps the API payload with pagination info.
type Response struct {
	Total int    `json:"total_count"`
	Page  int    `json:"page"`
	Items []Item `json:"items"`
}

func decodeResponse(raw string) Response {
	// Use a string reader to simulate an HTTP response body.
	reader := strings.NewReader(raw)

	var res Response
	// Decoder streams the JSON and stops on the first error.
	// It avoids allocating the full payload in memory.
	if err := json.NewDecoder(reader).Decode(&res); err != nil {
		fmt.Println("parse failed:", err)
		return Response{}
	}

	return res
}

func main() {
	payload := `{"total_count":2,"page":1,"items":[{"id":1,"tag":"alpha","meta":{"created_at":"2024-01-01","score":10}}]}`
	result := decodeResponse(payload)
	fmt.Println(result.Total, result.Items[0].Meta.Score)
}

The Response struct contains a slice of Item. The Item struct uses an anonymous nested struct for Meta. Anonymous structs work fine for quick scripts, but named types are easier to reuse and test. The decoder reads from the strings.Reader. It parses the JSON token by token. When it hits the items array, it allocates a slice and appends each decoded Item. If the JSON is malformed, Decode returns an error like invalid character '}' looking for beginning of object key string. The error message tells you exactly where the parser got confused.

Where things break

Nested JSON trips developers in three predictable ways. The first is pointer versus value semantics. If you define a nested field as a pointer, like Address *Address, the unmarshaler sets it to nil when the JSON key is missing. If you define it as a value, like Address Address, the unmarshaler leaves it at the zero value. Both are valid, but they behave differently when you later marshal the struct back to JSON. A nil pointer omits the field entirely. A zero-value struct serializes to an empty object with all zero fields.

The second pitfall is type mismatch. JSON numbers are floating point by default. If your struct expects an int but the JSON contains 3.14, the unmarshaler rejects it with json: cannot unmarshal number 3.14 into Go struct field Item.Count of type int. You can fix this by changing the Go field to float64 or by using json.Number for custom parsing.

The third pitfall is ignoring struct tag syntax. Tags must be straight double quotes. Backticks wrap the tag string, but the key-value pair inside uses double quotes. If you write `json:city` without quotes around the key, the parser treats it as a comment and ignores it. The compiler will not catch this mistake. The unmarshaler will silently skip the field, and your program will run with empty data. Always run go vet to catch malformed tags.

You also need to watch for unexported fields. If a nested struct has lowercase fields, the unmarshaler cannot touch them. The compiler rejects access to them from other packages, but within the same package you might accidentally leave them lowercase and wonder why the JSON never populates. The fix is simple: capitalize the field name and add a lowercase tag. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords in Go. Capitalization is the entire access control system.

Trust the zero value. If a field is missing from the JSON, it does not panic. It defaults to empty string, zero number, or nil pointer. Design your structs so that zero values are safe to use.

Custom unmarshaling for edge cases

Sometimes the JSON format requires conditional logic. You might need to parse a timestamp in multiple formats, or convert a string like "10.5MB" into a byte count. The standard unmarshaler cannot do this automatically. You implement the json.Unmarshaler interface by adding an UnmarshalJSON method to your type. The method receives the raw JSON bytes and writes the result into the receiver.

Here is a type that parses a flexible duration string.

package main

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

// FlexibleDuration holds a parsed time duration.
type FlexibleDuration struct {
	Duration time.Duration
}

// UnmarshalJSON parses the raw JSON bytes into a time.Duration.
// It handles both numeric seconds and human-readable strings.
func (fd *FlexibleDuration) UnmarshalJSON(data []byte) error {
	// Try parsing as a quoted string first.
	var s string
	if err := json.Unmarshal(data, &s); err == nil {
		d, err := time.ParseDuration(s)
		if err != nil {
			return fmt.Errorf("invalid duration string: %w", err)
		}
		fd.Duration = d
		return nil
	}

	// Fall back to parsing as a raw number (seconds).
	var secs float64
	if err := json.Unmarshal(data, &secs); err != nil {
		return err
	}
	fd.Duration = time.Duration(secs * float64(time.Second))
	return nil
}

func main() {
	var fd FlexibleDuration
	// Custom unmarshaler handles the string format gracefully.
	json.Unmarshal([]byte(`"5m30s"`), &fd)
	fmt.Println(fd.Duration)
}

The method signature must match the interface exactly. The receiver is a pointer because the method modifies the struct. The function unmarshals the raw bytes twice. First it tries a string. If that fails, it tries a number. This pattern is common when APIs change their format between versions. Accept interfaces, return structs. Your custom unmarshaler accepts the raw []byte interface and returns a concrete FlexibleDuration value.

Choosing your unmarshaling strategy

Use a named struct with explicit tags when the JSON shape is stable and you need type safety across your codebase. Use an anonymous struct when you are parsing a one-off payload and do not plan to reuse the type. Use a map[string]any when the JSON keys are dynamic or generated at runtime. Use json.Decoder when reading from a network stream or file to avoid allocating the entire payload in memory. Use json.Unmarshal when you already have the full byte slice in memory and want the fastest path to a typed value. Use a custom UnmarshalJSON method when the JSON format requires conditional logic or non-standard type conversions.

Struct tags are contracts. Keep them in sync with the API documentation. Let the compiler enforce the shape, not runtime guesses.

Where to go next