How to Unmarshal (Decode) JSON in Go

Decode JSON data into Go variables using the json.Unmarshal function from the encoding/json package.

The customs inspector model

You receive a JSON payload from an external API. It contains user profiles, configuration flags, and nested metadata. The raw bytes sit in a slice, completely opaque to your business logic. You need to extract specific values, validate their types, and hand them to the rest of your program. Go does not guess what you want. It requires you to declare the shape of the data upfront, then matches the incoming bytes against that shape.

Think of json.Unmarshal as a customs inspector at a port. The JSON payload is the shipping manifest. Your Go variable is the cargo container. The inspector reads the manifest line by line, checks each item against the container's declared slots, and either places the item inside or stops the shipment. If the manifest says age: "twenty-five" but the slot expects an integer, the inspector rejects the cargo and hands you a detailed error report. The process is explicit, type-safe, and leaves no room for silent data corruption.

How unmarshaling actually works

Go's standard library handles JSON decoding through a combination of byte scanning and reflection. The encoding/json package reads the raw byte slice, parses tokens (strings, numbers, booleans, objects, arrays), and walks the resulting tree. At each node, it consults the target Go value's type information. If the target is a struct, it matches JSON keys to struct fields using exported names or json tags. If the target is a map, it inserts key-value pairs dynamically. If the target is a slice, it appends elements sequentially.

Reflection is the engine here. Go cannot modify a value unless it holds a pointer to that value. The unmarshaler needs to write into your variable's memory, so it requires a pointer. Passing a value by copy would give the decoder a temporary shadow that gets discarded the moment the function returns. The pointer requirement is not a quirk. It is a direct consequence of how Go's reflection package mutates memory.

The type mapping follows strict rules. JSON numbers become float64 by default because JSON does not distinguish between integers and floats. JSON objects become map[string]any. JSON arrays become []any. If you want integers or custom types, you must declare them explicitly in your struct. The decoder will attempt type conversions when possible, but it will not silently truncate or coerce incompatible types.

Unmarshaling is deterministic. Given the same input and the same target type, it always produces the same result. It does not cache, it does not guess, and it does not mutate unrelated memory.

Pointers are required for mutation. Tags are required for flexibility. Trust the type system.

A minimal working example

Here is the simplest struct-based unmarshal: declare the shape, tag the fields, pass a pointer, handle the error.

package main

import (
	"encoding/json"
	"fmt"
)

// User represents a decoded API profile.
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

func main() {
	// Raw JSON payload from an external service.
	raw := []byte(`{"id": 42, "name": "Ada", "email": "ada@example.com"}`)

	// Target variable must be a pointer so reflection can modify it.
	var u User
	err := json.Unmarshal(raw, &u)
	if err != nil {
		// Fail fast on malformed input or type mismatches.
		fmt.Println("decode failed:", err)
		return
	}

	// Access decoded fields directly after successful unmarshal.
	fmt.Println(u.ID, u.Name, u.Email)
}

The json tags tell the decoder exactly which JSON keys map to which struct fields. Without tags, Go falls back to case-insensitive matching on exported field names. Tags remove ambiguity and allow you to keep Go's idiomatic capitalization while matching external API schemas. The error check follows the standard Go pattern: check immediately, return or handle, never ignore.

Tags are routing instructions. Errors are signals, not suggestions.

Walking through the runtime steps

When json.Unmarshal runs, the first step is validation. The scanner verifies that the byte slice contains valid JSON syntax. It checks for balanced braces, proper quoting, and correct comma placement. Invalid syntax triggers a json: invalid character error before any type mapping occurs.

Next, the decoder allocates or prepares the target value. Because you passed &u, it has a writable address. It inspects the User struct using reflection, builds a field map, and notes the json tags. It then walks the JSON object token by token. For each key, it looks up the corresponding struct field. If it finds a match, it reads the JSON value and converts it to the Go field's type.

Type conversion happens at this stage. The JSON number 42 becomes a Go int. The JSON string "Ada" becomes a Go string. If the JSON contains a key that does not match any struct field, the decoder ignores it silently. If the JSON is missing a key, the struct field keeps its zero value. This asymmetry is intentional: extra data is safe to ignore, missing data falls back to defaults.

If any conversion fails, the decoder stops immediately and returns an error. It does not partially populate the struct. The target variable remains in its original state, preventing half-decoded objects from leaking into your logic.

The process finishes by returning nil on success or a descriptive error on failure. No hidden state, no deferred mutations, no background parsing.

Reflection writes memory. Zero values fill gaps. Failures abort completely.

Realistic usage in an HTTP handler

Production code rarely unmarshals static byte slices. It reads from streams, validates payloads, and routes data to business logic. Here is how an HTTP handler decodes a JSON request body.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

// CreateRequest holds the payload for user creation.
type CreateRequest struct {
	Username string `json:"username"`
	Role     string `json:"role"`
}

// handleCreateUser processes incoming JSON payloads.
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
	// Limit body size to prevent memory exhaustion attacks.
	r.Body = http.MaxBytesReader(w, r.Body, 1024)

	var req CreateRequest
	// Decode directly from the request body stream.
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		// Return 400 for malformed JSON or missing required fields.
		http.Error(w, "invalid payload", http.StatusBadRequest)
		return
	}

	// Proceed with business logic using the validated struct.
	fmt.Fprintf(w, "created user %s with role %s", req.Username, req.Role)
}

func main() {
	http.HandleFunc("/users", handleCreateUser)
	fmt.Println("server listening on :8080")
	http.ListenAndServe(":8080", nil)
}

Using json.NewDecoder(r.Body).Decode instead of json.Unmarshal avoids reading the entire body into memory first. The decoder streams tokens directly from the io.Reader, which saves allocation and handles large payloads gracefully. The http.MaxBytesReader wrapper protects against denial-of-service attempts by capping the read size. The error handling follows the same pattern: check immediately, respond with an appropriate HTTP status, and exit the handler.

Streaming beats buffering. Validation belongs at the edge.

Common traps and compiler/runtime errors

Go's JSON package is strict by design. The most frequent mistake is passing a value instead of a pointer. The compiler will not catch this because json.Unmarshal accepts any. At runtime, reflection detects that the target is not settable and panics with reflect: Set: value of type main.User is not settable. Always pass &variable.

Unexported struct fields are silently ignored. If you define email string instead of Email string, the decoder cannot write to it. The JSON key passes through without error, and the field remains empty. This is not a bug. Go's visibility rules apply to reflection just as they do to direct access. Export the field or use a pointer to a private struct.

Type mismatches produce explicit errors. If the JSON contains a string where an integer is expected, the decoder returns json: cannot unmarshal string into Go struct field User.id of type int. If you pass a map[string]any but the JSON contains a top-level array, you get json: cannot unmarshal array into Go value of type map[string]interface {}. Read the error message. It tells you exactly which field failed and what type was expected.

Using map[string]any works for dynamic payloads, but it forces you to type-assert every value later. value, ok := m["id"].(float64) becomes tedious and error-prone. Structs eliminate type assertions and give you compile-time safety. Reserve maps for truly unknown schemas.

The json package also strips unknown fields by default. If you need to detect unexpected keys, implement the json.Unmarshaler interface or use json.Decoder with DisallowUnknownFields(). Strict decoding catches schema drift before it reaches production.

Pointers prevent panics. Exported fields enable reflection. Structs beat maps for known shapes.

Choosing your decoding strategy

Use json.Unmarshal with a typed struct when you know the exact shape of the payload and want compile-time safety. Use json.NewDecoder(reader).Decode when reading from streams, files, or HTTP bodies to avoid buffering the entire payload in memory. Use a map[string]any when the schema is completely dynamic and you cannot define a struct upfront. Use json.Decoder with DisallowUnknownFields() when you need strict validation that rejects unexpected keys. Use plain sequential parsing with encoding/json tokens when performance is critical and you only need a few specific values from a massive payload.

Structs enforce contracts. Streams preserve memory. Maps handle chaos. Strict decoders catch drift.

Where to go next