How to parse JSON

Parse JSON in Go using the encoding/json package's Unmarshal function to convert data into native variables.

The moment your API returns a blob

You send a request to an external service. The response arrives as a dense string of curly braces, quotes, and numbers. You need to extract a user ID, a timestamp, and a nested list of permissions. In JavaScript you call JSON.parse() and start dot-chaining immediately. In Go, the compiler refuses to let you touch that blob until you declare exactly what shape to expect. The language forces you to define the structure first. That friction is intentional. It turns a runtime guessing game into a compile-time contract.

How Go actually reads JSON

Go treats JSON as a serialization format, not a native data structure. The encoding/json package bridges the gap between a flat sequence of bytes and Go's strict type system. Think of it like a customs inspector at a border crossing. The inspector does not let cargo walk through unchecked. They verify the manifest, match the contents to the declared boxes, and reject anything that violates the paperwork. You write the manifest using struct fields and struct tags. The tags tell the parser which JSON key maps to which Go field. The parser reads the bytes, matches them to your manifest, and populates your memory. If a key is missing, the parser leaves the Go variable at its zero value. If a type mismatches, the parser stops and returns an error.

Define the shape first. Let the parser fill the boxes.

The minimal unmarshal

Here is the standard pattern for turning a JSON string into a typed struct.

package main

import (
	"encoding/json"
	"fmt"
)

// UserProfile holds the data we expect from the API.
type UserProfile struct {
	// json:"name" maps the "name" key in the JSON to this field.
	DisplayName string `json:"name"`
	// json:"age" tells the parser to look for an integer.
	Age int `json:"age"`
}

// main demonstrates basic JSON parsing.
func main() {
	// Raw JSON arrives as a byte slice from the network.
	payload := []byte(`{"name":"Alice","age":30}`)

	var user UserProfile
	// Unmarshal reads the bytes and writes into the struct pointer.
	err := json.Unmarshal(payload, &user)
	if err != nil {
		// The parser failed. We stop immediately.
		panic(err)
	}

	fmt.Println(user.DisplayName, user.Age)
}

The struct tags are the bridge between JSON keys and Go identifiers. Go field names are exported by default when they start with a capital letter. The json:"key" tag overrides the default name matching. Without the tag, the parser would look for "DisplayName" and "Age" in the JSON. Most APIs use snake_case or camelCase. The tag keeps your Go code idiomatic while satisfying the external format.

What happens under the hood

The compiler checks that the second argument to json.Unmarshal is a pointer to a struct, map, or slice. If you pass a value instead of a pointer, the compiler rejects the program with json: Unmarshal(non-pointer main.UserProfile). The pointer is required because the parser needs a memory address to write the decoded values into.

At runtime, json.Unmarshal uses reflection. Reflection is a set of functions that inspect types, fields, and methods while the program is running. The parser walks the byte slice, builds an internal tree of values, and then uses reflection to match those values to your struct fields. Reflection is slower than direct assignment. The Go team optimized the parser heavily, but it still carries overhead compared to hand-written code. The trade-off is flexibility. You get a general-purpose decoder that works with any struct without generating custom code.

The parser skips unknown keys by default. It leaves unexported fields alone. It respects zero values for missing keys. If the JSON contains null for a field, the parser sets the Go field to its zero value. A missing key and a null value behave identically. If you need to distinguish between them, you must use pointer fields. A pointer starts as nil. The parser sets it to a valid address only when the key exists and is not null.

Reflection handles the mapping. You handle the types.

Real-world parsing with validation

Production code rarely deals with perfect JSON. APIs drop keys, rename fields, or send numbers as strings. Here is how you handle a realistic payload with optional fields and strict error reporting.

package main

import (
	"encoding/json"
	"fmt"
)

// APIResponse represents a typical third-party payload.
type APIResponse struct {
	// json:",omitempty" skips this field if it is empty when encoding.
	Status string `json:"status"`
	// Nested struct keeps related data grouped and type-safe.
	Data struct {
		ID   int    `json:"id"`
		Role string `json:"role"`
	} `json:"data"`
	// json.RawMessage defers parsing of unknown or complex sections.
	Metadata json.RawMessage `json:"metadata,omitempty"`
}

// parseResponse converts raw bytes into a typed response.
func parseResponse(body []byte) (APIResponse, error) {
	var resp APIResponse
	// Unmarshal populates the struct. It returns immediately on type mismatch.
	if err := json.Unmarshal(body, &resp); err != nil {
		// Wrap the error to preserve the call stack context.
		return resp, fmt.Errorf("decode payload: %w", err)
	}
	return resp, nil
}

The json.RawMessage type is just an alias for []byte. It tells the parser to copy the raw JSON bytes for that section without decoding them. You use it when you need to pass a nested object to another service, or when you want to validate a section manually before committing to a full struct. The %w verb in fmt.Errorf wraps the original error. Wrapping preserves the error chain so you can unwrap it later with errors.Is or errors.As. The community accepts the if err != nil boilerplate because it makes the unhappy path visible. You do not hide failures behind silent returns.

Missing keys become zero values. Type mismatches become errors. Check both.

Where things go wrong

The most common mistake is forgetting the ampersand. Passing user instead of &user triggers json: Unmarshal(non-pointer main.UserProfile). The parser cannot modify a value passed by copy. Always pass a pointer to the target variable.

Type mismatches surface as runtime errors, not compile-time errors. If the JSON contains "age": "thirty" but your struct expects an int, the parser stops and returns json: cannot unmarshal string into Go struct field User.Age of type int. Go does not attempt implicit conversion. A string stays a string. A number stays a number. If the API is inconsistent, you can use any (or interface{} in older code) to accept anything, then type-assert later. You can also use a custom UnmarshalJSON method on your struct to handle messy inputs gracefully.

Unexported fields are silently ignored. If you write name string instead of Name string, the parser skips it. There is no warning. The field remains empty. This is a deliberate design choice. The JSON package respects Go's visibility rules. If a field is private to the package, external data cannot touch it.

The encoding/json package also respects the string tag option. Adding json:"age,string" tells the parser to expect a quoted string and convert it to the target type. It handles the conversion safely and returns an error if the string is not a valid number.

Trust the type system. Wrap the value or change the design.

Picking the right parsing strategy

Use json.Unmarshal with a struct when you know the exact shape of the data and want compile-time safety. Use json.Unmarshal with map[string]any when the JSON structure is dynamic or you only need to extract a few scattered values. Use json.NewDecoder(reader) when streaming large payloads or reading directly from an HTTP response body to avoid allocating the full byte slice in memory. Use json.RawMessage when you need to defer parsing of a nested section or pass unknown JSON through to another service. Stick to sequential parsing when performance is tight: reflection has overhead, and hand-written parsers beat encoding/json for hot paths.

Structs for safety. Maps for flexibility. Decoders for streams.

Where to go next