How to Unmarshal JSON into a map[string]interface{} in Go

Decode JSON bytes into a flexible map[string]interface{} using json.Unmarshal.

The dynamic JSON problem

You receive a JSON payload from a third-party API. The documentation says the response shape changes depending on the user tier. Sometimes it returns a flat object. Sometimes it nests arrays inside objects. Sometimes it adds new fields without warning. You need to parse it without writing a rigid struct that breaks the moment the API updates.

Go is statically typed. Every variable has a known type at compile time. JSON is dynamically typed. It ships as text with keys and values that can be strings, numbers, booleans, arrays, or nested objects. To bridge that gap, Go gives you map[string]interface{}. The interface{} part is Go's way of saying "I don't know the type yet, but I'll hold whatever shows up." It is a box that can contain anything. When you unmarshal into it, the JSON decoder guesses the Go type for each value and packs it inside.

How Go handles unknown shapes

Think of interface{} as a universal shipping container. Inside every container are two slots: one for the actual data, and one for a type tag that says what the data is. When the JSON decoder reads a value, it allocates the right Go type, puts it in the data slot, stamps the type tag, and drops the whole container into your map.

This design lets you accept completely unknown JSON without defining a struct. The tradeoff is that you lose compile-time guarantees. You have to check the type at runtime before you can use the value. That extra step is the price of flexibility.

The minimal unmarshal

Here is the simplest way to decode raw JSON bytes into a dynamic map.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	// Raw JSON payload from an API or file
	jsonBytes := []byte(`{"name": "Alice", "age": 30, "active": true}`)

	// Declare the map. Zero value is nil, which Unmarshal handles safely.
	var data map[string]interface{}

	// Unmarshal populates the map. The address operator is required.
	err := json.Unmarshal(jsonBytes, &data)
	if err != nil {
		// Return early on failure. Verbose error handling is intentional in Go.
		fmt.Println("parse failed:", err)
		return
	}

	// The map now holds the decoded values.
	fmt.Println(data)
}

The jsonBytes variable must be a byte slice. Unmarshal reads the slice sequentially and builds the Go data structure in memory. The &data pointer tells the decoder where to write the result. If data is nil, the decoder allocates a new map for you. If it already contains values, they get overwritten by the new keys.

What happens under the hood

The decoder walks through the JSON token by token. When it hits a string, it creates a Go string. When it hits a boolean, it creates a bool. When it hits a number, it creates a float64. That last part catches most developers off guard. The JSON specification does not distinguish between integers and floating-point numbers. Go picks float64 because it can safely hold any JSON number without losing precision.

Nested objects become map[string]interface{}. Arrays become []interface{}. The decoder recurses until it reaches the leaves. Every value ends up boxed inside an interface{} container. That boxing means extra memory allocations and a small performance cost compared to decoding directly into a typed struct.

Working with the result

You cannot use the values directly. The compiler only knows they are interface{}. You must extract them using a type assertion or a type switch. A type switch is the safest pattern because it checks the type and handles each case explicitly.

// Extract values safely using a type switch
name, ok := data["name"].(string)
if !ok {
	// The key exists but holds a different type, or the key is missing.
	return
}

// Handle numeric values carefully. JSON numbers are always float64.
ageFloat, ok := data["age"].(float64)
if !ok {
	return
}
age := int(ageFloat) // Convert to int if you need integer arithmetic

// Check booleans the same way
isActive := false
if v, ok := data["active"].(bool); ok {
	isActive = v
}

Type assertions return two values: the extracted value and a boolean indicating success. If the types do not match, the boolean is false and the value is the zero value of the target type. This pattern prevents runtime panics. You can also use a switch statement with type cases to handle multiple keys at once, which keeps the code readable when you are dealing with dozens of fields.

Pitfalls and compiler traps

The compiler will reject code that tries to use an interface{} value as a concrete type without an assertion. If you write fmt.Println(data["name"] + " Smith"), you get invalid operation: operator + not defined on interface{}. The compiler forces you to be explicit about types.

Runtime panics happen when you use the single-value form of a type assertion. Writing name := data["name"].(string) assumes the type is correct. If the JSON contains a number instead of a string, the program crashes with panic: interface conversion: interface {} is float64, not string. Always use the two-value form or a type switch in production code.

Performance is the other hidden cost. Boxing values into interface{} allocates memory on the heap. Unboxing them later requires a type check and a pointer dereference. If you are parsing millions of records or building a high-throughput API, this pattern will show up in p99 latency reports. The decoder also creates a new map and slice for every nested object or array. Memory usage scales with the depth and size of the JSON.

Go 1.18 introduced any as an alias for interface{}. The compiler treats them identically. Use any in new code for readability, but know that older tutorials and standard library examples still use interface{}. The convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors or wrap them in silent if blocks.

When to use maps versus structs

Use a map[string]interface{} when the JSON shape is truly unknown or changes frequently. Use a map[string]interface{} when you are building a generic JSON transformer that routes payloads to different handlers. Use a typed struct when the schema is stable and you want compile-time safety. Use a typed struct when performance matters and you want to avoid heap allocations. Use json.RawMessage when you need to defer parsing of a specific field until later. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Dynamic maps are a tool for flexibility, not a default. The moment you start writing type switches for every field, you have essentially rebuilt a struct by hand. At that point, defining a proper type pays for itself in readability and safety.

Where to go next