Nested structs in JSON
You receive a JSON payload from a frontend client. It contains a user object, and inside that user object sits an address, which itself holds a list of tags. You need to turn that text blob into Go values so you can query the database. Later, you construct a response struct and need to send it back as JSON. Nested data structures are the default in web development. Go handles them with encoding/json, but the mechanics of how the serializer walks your types matter more than people expect.
How marshaling and unmarshaling work
Marshaling converts a Go value into a byte slice, usually JSON. Unmarshaling does the reverse: it takes bytes and populates a Go value. When structs nest inside other structs, the process is recursive. The serializer visits the outer struct, sees a field that is itself a struct, and dives in. It repeats this until it hits a primitive type like a string or integer. Struct tags control the mapping between Go field names and JSON keys. Without tags, Go uses the field name as the key. With tags, you override that behavior.
Tags are the contract. Reflection is the engine.
Minimal example
Here's the baseline pattern: define nested types, marshal to bytes, unmarshal back to a new instance.
package main
import (
"encoding/json"
"fmt"
)
// Address represents a location with city and state.
type Address struct {
City string `json:"city"`
State string `json:"state"`
}
// Person holds user details including a nested address.
type Person struct {
Name string `json:"name"`
Address Address `json:"address"`
}
func main() {
p := Person{
Name: "Alice",
Address: Address{
City: "NYC",
State: "NY",
},
}
// Marshal converts the struct tree to JSON bytes.
data, err := json.Marshal(p)
if err != nil {
panic(err)
}
fmt.Println(string(data))
// Unmarshal parses JSON bytes back into a struct.
var p2 Person
err = json.Unmarshal(data, &p2)
if err != nil {
panic(err)
}
fmt.Println(p2.Name, p2.Address.City)
}
What happens at runtime
When you call json.Marshal, the runtime uses reflection to inspect the type of the value. It walks the struct fields, checks for tags, and serializes each field. If a field is a struct, the function recurses. The result is a byte slice containing the JSON representation. Unmarshaling works similarly but in reverse. The parser reads the JSON object, finds keys, and matches them to struct fields. If a field is a struct, it creates a new instance and recurses. The match is case-insensitive by default, but tags take precedence. Reflection adds overhead. For tight loops, consider code generation or flat structures.
Realistic API response
Real-world APIs often wrap data in metadata. This example shows a nested response with an anonymous struct for the metadata section.
// Coordinate holds latitude and longitude.
type Coordinate struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
// Location represents a named place with coordinates.
type Location struct {
Name string `json:"name"`
Coordinates Coordinate `json:"coordinates"`
}
// Response wraps the payload with metadata using an anonymous struct.
type Response struct {
Status string `json:"status"`
Data Location
Metadata struct {
Version string `json:"version"`
} `json:"metadata"`
}
The builder function constructs the response and marshals it. Notice the error handling follows the standard pattern.
// BuildResponse marshals a location into a structured API response.
func BuildResponse(loc Location) ([]byte, error) {
resp := Response{
Status: "ok",
Data: loc,
Metadata: struct {
Version string `json:"version"`
}{
Version: "1.0",
},
}
// Marshal returns JSON bytes or an error if the type is unsupported.
return json.Marshal(resp)
}
func main() {
loc := Location{
Name: "Empire State Building",
Coordinates: Coordinate{
Lat: 40.748817,
Lng: -73.985428,
},
}
data, err := BuildResponse(loc)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
Pointers and optional fields
Nested structs often contain optional data. A user might have an address, or they might not. If you define the address as a value type, unmarshaling creates an empty address struct even if the JSON lacks the field. You cannot distinguish between a missing address and an address with empty strings. Pointers solve this. When the JSON key is absent, the pointer remains nil. When the key is present, the JSON package allocates the struct and populates it.
Use pointers for optional nested fields. Use values for required fields.
// UserWithOptionalAddress shows how pointers handle missing data.
type UserWithOptionalAddress struct {
Name string `json:"name"`
Address *Address `json:"address"`
}
func main() {
jsonNoAddress := `{"name": "Bob"}`
var u UserWithOptionalAddress
json.Unmarshal([]byte(jsonNoAddress), &u)
// Address is nil because the key was missing.
if u.Address == nil {
fmt.Println("No address provided")
}
}
Struct tag options
Tags support options beyond the key name. The omitempty option tells the marshaler to skip the field if it has the zero value. This reduces JSON size and prevents sending empty data. The string option allows numbers to be marshaled as JSON strings. This preserves precision for large integers that might overflow a standard 64-bit float in JavaScript.
Tags control the wire format. Keep them consistent.
// Config shows common tag options.
type Config struct {
// Omitempty skips the field if the slice is nil or empty.
Tags []string `json:"tags,omitempty"`
// String option marshals the ID as a JSON string to preserve precision.
ID int64 `json:"id,string"`
}
Streaming with encoders and decoders
json.Marshal allocates a byte slice for the entire output. For large payloads, this wastes memory. json.NewEncoder writes directly to an io.Writer. It streams the output without building the full byte slice in memory. Similarly, json.NewDecoder reads from an io.Reader. This is the standard pattern for HTTP handlers. You pass the request body to the decoder and the response writer to the encoder.
Pick the tool that matches the data flow. Bytes for buffers, streams for I/O.
import (
"encoding/json"
"io"
)
// StreamResponse demonstrates encoding directly to a writer.
func StreamResponse(w io.Writer, data interface{}) error {
// Encoder writes JSON directly to the writer without intermediate allocation.
enc := json.NewEncoder(w)
return enc.Encode(data)
}
Pitfalls and errors
Unexported fields are invisible to the JSON package. If a field starts with a lowercase letter, the marshaler skips it and the unmarshaler cannot populate it. The compiler won't catch this, but the JSON output will be missing the field, and unmarshaling will silently ignore the data. If you pass a value instead of a pointer to json.Unmarshal, the function cannot modify the original variable. The compiler rejects this with cannot use p (type Person) as type *Person in argument. You must pass the address of the struct. Type mismatches cause runtime errors. If the JSON contains a string where the struct expects an integer, unmarshaling fails with json: cannot unmarshal string into Go struct field ... of type int. The error message tells you the field and the expected type.
Unexported fields are invisible. Pass pointers to unmarshal.
When to use what
Use json.Marshal when you need to convert a struct to a JSON byte slice for storage or transmission. Use json.Unmarshal when you have JSON bytes and need to populate a struct with the data. Use json.NewEncoder when you are writing directly to an io.Writer like an HTTP response body to avoid allocating intermediate byte slices. Use json.NewDecoder when you are reading from an io.Reader like an HTTP request body to stream the data without loading the entire payload into memory. Use struct tags when the JSON key names differ from your Go field names or when you need to control omitempty behavior. Use pointers in structs when a field is optional and you need to distinguish between a zero value and a missing value.
Conventions to follow
The community expects if err != nil checks immediately after every function call that returns an error. Skipping the check hides failures and makes debugging harder. Struct tags should be consistent. Use json:"name" format. If a field should be omitted when empty, add omitempty to the tag. The receiver name for methods on structs is usually one or two letters matching the type, like (p *Person), not (this *Person). gofmt formats the code. Trust the formatter.