How to Convert JSON to a Struct in Go

Decode JSON data into a Go struct using the encoding/json.Unmarshal function with a pointer to the target struct.

The contract before the data

You have spent months writing Python or JavaScript. You fetch data from an API, call a parse function, and suddenly you have a dictionary or object with properties you can access immediately. You switch to Go, write the fetch, and realize there is no equivalent parse function. Instead, you are staring at a struct definition and a function called Unmarshal. It feels like Go is forcing you to write extra boilerplate just to read some text.

Go is asking you to make a promise about the shape of your data before you even look at it. That promise is the struct. JSON is a stream of bytes with no type information attached. Go refuses to guess what those bytes mean. You provide the mold, and Go pours the data into it. If the JSON has a key that does not match a field in your struct, Go ignores it. If the JSON is missing a key, Go leaves the field at its zero value. If the types do not match, Go stops and tells you exactly where the mismatch happened. This strictness prevents the silent runtime crashes that happen when data shapes change unexpectedly.

The struct is the contract. Define it well.

How the decoder actually works

json.Unmarshal takes two arguments. The first is the source data as a byte slice. The second is a pointer to the destination struct. You pass a pointer because Unmarshal needs to modify the struct in place. If you passed the struct by value, Go would make a copy, fill the copy, and throw it away. The pointer ensures the changes stick to the variable you care about.

The function returns an error. If the JSON is malformed, or if a value does not fit the type, the error tells you what went wrong. The json tags on your struct fields control how keys map to fields. Go matches JSON keys to struct fields by name, case-insensitive. Tags override this behavior and let you map user_name to UserName. Without tags, Go uses the struct field name as the JSON key, which rarely matches API conventions.

Here is the simplest way to decode JSON. You define the struct, tag the fields, and call Unmarshal.

package main

import (
	"encoding/json"
	"fmt"
)

// User holds the data we expect from the API.
type User struct {
	// The json tag maps the struct field to the JSON key.
	Name  string `json:"name"`
	Email string `json:"email"`
}

func main() {
	// Raw JSON bytes from a response or file.
	data := []byte(`{"name":"Alice","email":"alice@example.com"}`)

	var u User
	// Unmarshal fills the struct pointed to by &u.
	err := json.Unmarshal(data, &u)
	if err != nil {
		// Handle the error; don't panic in production.
		fmt.Println("Error:", err)
		return
	}

	fmt.Println(u.Name)
}

The boilerplate if err != nil is verbose by design. The community accepts the repetition because it makes the unhappy path visible. You cannot accidentally ignore a decode failure. The compiler forces you to acknowledge the error return value. If you try to assign the result to _, the code still compiles, but you lose the safety net. Write the check.

Goroutines are cheap. Error checks are mandatory.

Real API responses and optional fields

Real APIs return nested objects and optional fields. Here is how to handle a response with a nested address and a field that might be missing.

package main

import (
	"encoding/json"
	"fmt"
)

// Address represents the nested location data.
type Address struct {
	Street string `json:"street"`
	City   string `json:"city"`
}

// Profile holds user details including optional metadata.
type Profile struct {
	ID    int     `json:"id"`
	Name  string  `json:"name"`
	// Use a pointer for optional fields. If the JSON key is missing,
	// the pointer remains nil instead of a zero value.
	Address *Address `json:"address,omitempty"`
}

func main() {
	// JSON with a missing address field.
	data := []byte(`{"id":42,"name":"Bob"}`)

	var p Profile
	err := json.Unmarshal(data, &p)
	if err != nil {
		fmt.Println("Decode failed:", err)
		return
	}

	// Check if the optional field was present.
	if p.Address == nil {
		fmt.Println("No address provided")
	}
	fmt.Printf("User %d: %s\n", p.ID, p.Name)
}

The omitempty tag option is a convention trap. It only affects encoding. When you write a struct back to JSON, omitempty skips zero values. It does nothing during decoding. If you want to detect missing fields during decoding, use a pointer. A pointer is nil when the key is absent. A value type like string becomes an empty string, which is indistinguishable from a field that was explicitly set to empty.

Pointers are the only reliable way to distinguish between "missing" and "empty". Use them for optional API fields.

Tag options and edge cases

JSON tags support options that handle edge cases without custom code. You can omit a field entirely from JSON by using json:"-". This is useful for sensitive data like passwords that you decode but never encode back. You can also handle type coercion. If an API sends a number as a string, add json:",string" to the tag. Unmarshal will convert the string to the number automatically. This saves you from manual parsing code.

type Config struct {
	// Skip this field during both encoding and decoding.
	Secret string `json:"-"`
	// API sends port as "8080", but we want an int.
	Port   int    `json:"port,string"`
}

The json:"-" tag is the standard way to exclude fields. The string option is the standard way to handle APIs that treat everything as strings. Use these options before reaching for custom unmarshaling logic. If you do write a custom UnmarshalJSON method, follow the receiver naming convention: use one or two letters matching the type, like (c *Config) UnmarshalJSON, not (this *Config) or (self *Config). The standard library uses short receiver names, and your code should match that rhythm.

Tags handle 90 percent of weird API formats. Reach for custom logic only when tags fail.

Streaming and strict mode

json.Unmarshal requires the data as a []byte. If you are reading from an io.Reader, like an HTTP response body, you have two choices. You can read the body into a buffer and unmarshal, or you can use json.NewDecoder. The decoder reads directly from the stream. It buffers internally and decodes as it goes. This uses less memory for large payloads.

The decoder also lets you set limits. You can call decoder.DisallowUnknownFields() to reject JSON that contains keys your struct does not know about. This is a safety check. It catches typos in the API response or schema changes that your code has not been updated for. Without this check, Unmarshal silently ignores unknown keys.

func handleResponse(w http.ResponseWriter, r *http.Request) {
	// Create a decoder that reads directly from the request body.
	decoder := json.NewDecoder(r.Body)
	// Reject JSON with keys that don't match the struct.
	decoder.DisallowUnknownFields()

	var req CreateUserRequest
	err := decoder.Decode(&req)
	if err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	// Process req...
}

Streaming avoids loading megabytes of JSON into memory just to extract three fields. Strict mode catches schema drift before it reaches your database.

Stream when you can. Unmarshal when you must. Decode when you read.

Pitfalls and runtime errors

If you define a field with a lowercase name, Unmarshal ignores it. The compiler will not stop you, but the data will not populate. The struct field must start with a capital letter. Go visibility rules apply to reflection. The JSON package uses reflection to set fields, and reflection cannot touch private fields.

Reflection respects visibility. Capitalize fields or the data stays out.

If the JSON contains a number but your struct expects a string, Unmarshal returns an error. The error message looks like json: cannot unmarshal number into Go struct field User.age of type string. This tells you the key, the field, and the mismatch. Read the error. It points directly to the problem.

Struct fields that are slices or maps get allocated automatically. If you have a field of type []string, Unmarshal creates the slice. If you have a field of type *[]string, Unmarshal leaves the pointer nil. You must initialize the target before decoding, or change the field to a non-pointer type. Passing a pointer to a slice or map is rarely needed. Use the value type for the field and let Unmarshal handle the allocation.

The worst decode bug is the one that silently drops data. Check your struct tags and field visibility before blaming the API.

When to use what

Use json.Unmarshal when you have the full JSON payload in memory and need to decode it into a known structure. Use json.NewDecoder(reader).Decode when you are streaming data from a network connection or file and want to avoid loading the entire body into a byte slice first. Use a map[string]any when the JSON structure is dynamic or unknown, and you need to inspect keys at runtime. Use a custom UnmarshalJSON method when the JSON format requires special logic, like parsing a date string into a time.Time or handling multiple possible types for a single field. Use json.RawMessage when you need to defer decoding of a nested field until later, or when you want to pass through a JSON fragment without parsing it.

Pick the tool that matches your data shape. Don't overengineer the decoder.

Where to go next