When the JSON shape changes
You are building a webhook handler for a third-party service. The documentation says the payload varies by event type. Sometimes you get a user signup with an email and name. Sometimes you get a payment failure with a transaction ID and error code. You could write a massive struct with every possible field, but that feels brittle. New fields appear, optional fields become required, and the schema drifts. Or you receive a JSON blob from an API and you only care about one deeply nested value, but the path depends on runtime data. Go's type system loves structure, but the web is messy. You need a way to accept JSON without knowing its shape ahead of time.
The two standard tools
Go provides two standard approaches for dynamic JSON. The first is map[string]any. This unmarshals the JSON into a generic map where keys are strings and values can be anything. The second is json.RawMessage. This is a type alias for []byte. It tells the decoder to skip parsing a field and just save the raw JSON bytes. You parse it later, or not at all.
Think of map[string]any like a mesh cargo crate. You throw everything in, and you sort through it later by feeling around for what you need. Think of json.RawMessage like a sealed envelope. You know it contains data, but you leave it unopened until you have a reason to look inside. Both approaches trade compile-time guarantees for runtime flexibility. Pick the one that matches your parsing strategy.
Unmarshaling into a map
Here is the simplest way to accept unknown JSON and extract a value safely.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// Raw JSON string with mixed types
jsonStr := `{"name": "Alice", "age": 30, "active": true}`
// Map keys are always strings. Values can be any type.
var data map[string]any
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
fmt.Println("Error:", err)
return
}
// Type assertion checks the dynamic type at runtime.
if name, ok := data["name"].(string); ok {
fmt.Println("Name:", name)
}
// Numbers come back as float64 by default.
if age, ok := data["age"].(float64); ok {
fmt.Println("Age:", int(age))
}
}
Maps are flexible. Type assertions are runtime checks. Use comma-ok or risk a panic.
How the decoder decides types
When you unmarshal into map[string]any, the encoding/json package inspects the JSON tokens and builds a map. It infers the Go type for each value. Strings map to string. Booleans map to bool. Arrays become []any. Objects become nested map[string]any.
Numbers always map to float64. This is a common surprise. If the JSON contains 42, the map holds a float64 with value 42.0. If you try to assert int, the assertion fails. The compiler will not stop you, but the runtime will reject the assertion. You must assert float64 and convert to int if needed. The decoder makes this choice because JSON does not distinguish between integers and floating-point numbers. A float64 can represent any valid JSON number without loss of precision for typical payloads.
Null values in JSON become nil in Go. A map entry for a null value holds a nil interface. Accessing it requires checking for nil before asserting. If you assert a type on a nil interface, the assertion returns false.
Go 1.18 introduced any as an alias for interface{}. Modern code uses any. Older codebases might still use interface{}. They are identical. The community convention is to use any in new code. It reads cleaner and signals that the type is intentionally open.
The decoder also respects struct tags when you unmarshal into a struct, but maps ignore tags entirely. Map keys are always the exact strings from the JSON. Case sensitivity matters. Name and name are different keys.
Maps trade safety for flexibility. Verify every assertion.
Lazy parsing with RawMessage
Real code often needs partial parsing. You receive a large JSON document but only care about a few fields. Or the structure of a nested object depends on a top-level discriminator. json.RawMessage solves this. You define a struct with a field of type json.RawMessage. The decoder fills that field with the raw JSON bytes. It does not parse the content. You can inspect the discriminator field, then call json.Unmarshal on the raw bytes to parse into the correct type.
Here is how to parse a discriminator and lazily extract the payload.
package main
import (
"encoding/json"
"fmt"
)
// Event captures the top-level type and defers payload parsing.
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
func process(jsonData []byte) {
var event Event
// Top-level unmarshal keeps Payload as raw bytes.
if err := json.Unmarshal(jsonData, &event); err != nil {
fmt.Println("Error:", err)
return
}
// Parse Payload only if the type matches.
if event.Type == "signup" {
var email string
// Extract just the email from the raw payload.
if err := json.Unmarshal(event.Payload, &email); err != nil {
fmt.Println("Inner error:", err)
return
}
fmt.Println("Email:", email)
}
}
json.RawMessage is literally type RawMessage []byte. You can convert it to a string with string(rawMsg) to log the raw JSON, or pass it directly to another unmarshal call. This is useful for debugging or forwarding payloads to a downstream service without touching the data.
RawMessage is lazy. Parse only what you need.
Pitfalls and runtime failures
Dynamic JSON moves errors from compile time to runtime. The compiler cannot check types. If you expect a string and get a number, the type assertion fails. Without the comma-ok idiom, your program panics with interface conversion: interface {} is float64, not string. Always use the comma-ok form val, ok := data["key"].(string) with dynamic data. The comma-ok pattern returns a boolean that tells you whether the assertion succeeded. It prevents crashes and lets you handle missing or mismatched fields gracefully.
If you unmarshal into the wrong type, the decoder rejects the input. Passing a string variable to json.Unmarshal when the JSON is an object produces json: cannot unmarshal object into Go value of type string. The error message tells you exactly what went wrong. Check the error before proceeding.
Performance is another concern. Unmarshaling into map[string]any allocates a new map and interface for every node. It is significantly slower than unmarshaling into a struct. For a 10MB JSON file, the memory usage can spike. If you are processing high-throughput data, map[string]any might trigger GC pressure. json.RawMessage avoids this by keeping data as a byte slice. You can also use json.Decoder with a buffer to stream data instead of loading everything into memory at once. Streaming decoders read tokens one at a time and skip branches you do not need.
The community convention for error handling applies here too. if err != nil { return err } is verbose by design. The boilerplate makes the unhappy path visible. Do not swallow errors from json.Unmarshal. Log them or return them. Silent failures in JSON parsing turn into corrupted state downstream.
Dynamic JSON moves errors from compile time to runtime. Test the edge cases.
Decision: struct, map, or raw bytes
Use a typed struct when the JSON schema is stable and known. Structs give you compile-time safety, faster unmarshaling, and clear documentation.
Use map[string]any when the keys vary or the structure changes per request. This works for config files, generic webhooks, or debugging tools that need to inspect arbitrary JSON.
Use json.RawMessage when you need to parse a subset of the JSON or defer parsing based on a discriminator field. This avoids parsing data you will never use and handles polymorphic payloads efficiently.
Use a third-party library like github.com/tidwall/gjson when you need to extract values from deep paths without defining any types. GJSON uses a path syntax and is optimized for read-only access to large documents.
Structs for safety. Maps for chaos. RawMessage for control.