25.5 Handling Dynamic JSON: json.RawMessage and map[string]any
Alright, let’s talk about the moment every Go developer faces: when your JSON isn’t a nice, tidy struct. It’s a moving target. Maybe it’s a third-party API that sends different object shapes based on some "type" field, or a config file with deeply nested, arbitrary blobs. You can’t define a static struct ahead of time, so you reach for the universal key: interface{}. Or, as it’s been mercifully renamed in Go 1.18, any.
This is where you have two main weapons in your arsenal: map[string]any and json.RawMessage. One is a quick and dirty solution that often turns into a dirty solution, and the other is your get-out-of-jail-free card for precise control. Let’s break them down.
The Siren Song of map[string]any
The easiest way to unmarshal dynamic JSON is to just tell the encoding/json package to shove everything into a map.
data := []byte(`{
"name": "Alice",
"age": 30,
"preferences": {
"theme": "dark",
"notifications": true
}
}`)
var result map[string]any
err := json.Unmarshal(data, &result)
if err != nil {
log.Fatal(err)
}
// Accessing the data requires type assertions. Every. Single. Time.
name := result["name"].(string) // hope it's a string!
age := result["age"].(float64) // wait, why a float64?!
prefs := result["preferences"].(map[string]any)
theme := prefs["theme"].(string)
See that .(float64)? That’s the first gut punch. The JSON spec doesn’t distinguish between integers and floats, so encoding/json, in its infinite wisdom, defaults to float64 for any number. It’s a classic Go gotcha. You now have to either cast it to an int (int(age)) or, better, use a type switch to handle it safely.
This approach is fine for a quick script or for data you’re immediately passing on without much inspection. But for anything serious, it’s a nightmare. You’re writing a lot of boilerplate, error-prone type assertion code, and you’ve lost all type safety. It’s like choosing to navigate by smell instead of sight.
Your Precision Scalpel: json.RawMessage
This is the pro move. json.RawMessage is just a []byte alias that holds the raw, unprocessed JSON for a specific field. Instead of unmarshaling immediately, you delay it. This allows you to inspect the data first and then decide how to unmarshal it.
Let’s say you get a JSON payload where the "payload" field could be one of two different structs.
type Message struct {
ID int `json:"id"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"` // The magic is here
}
type UserPayload struct {
Name string `json:"name"`
Role string `json:"role"`
}
type SystemPayload struct {
Event string `json:"event"`
Code int `json:"code"`
}
func main() {
data := []byte(`{
"id": 1,
"type": "user",
"payload": {"name": "Alice", "role": "admin"}
}`)
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Fatal(err)
}
var payload any
switch msg.Type {
case "user":
payload = new(UserPayload)
case "system":
payload = new(SystemPayload)
default:
log.Fatalf("unknown message type: %q", msg.Type)
}
// Now, and only now, do we unmarshal the specific payload
if err := json.Unmarshal(msg.Payload, payload); err != nil {
log.Fatal(err)
}
fmt.Printf("Decoded payload: %+v\n", payload)
// Output: Decoded payload: &{Name:Alice Role:admin}
}
Why is this so much better?
- Performance: You only unmarshal what you need, when you know what it is. No wasted cycles parsing the entire blob into a massive
map[string]any. - Type Safety: The second unmarshal step is into a properly defined struct. You get all the benefits of struct tags and compile-time checks.
- Flexibility: You can inspect the message (e.g., the
"type"field) to decide which struct to use, or even try multiple unmarshal attempts.
Best Practices and Pitfalls
- Don’t Mix Them Unnecessarily: A common anti-pattern is using
map[string]anyand then trying to unmarshal a single value from it. If you find yourself doing this, you should have usedjson.RawMessageat a higher level to defer the decision. - Validation is on You: With
RawMessage, you’ve delayed unmarshaling, but you still have to do it. And when you do, you must handle errors. The first unmarshal (into the struct containing theRawMessage) only checks for valid JSON, not the validity of the content within theRawMessage. - For
slogAttributes: This is wheremap[string]anyshines. When adding context to a log withslog, you often want to add arbitrary, dynamic key-value pairs. Theslog.Anyfunction and theLogValuerinterface are your friends here for creating structured log attributes, which are essentiallymap[string]anyunder the hood. It’s the perfect use case because the data is being immediately serialized back to JSON for the log output—no further processing is needed.
So, the rule of thumb is simple: if you need to work with the data, use json.RawMessage for precision and safety. If you’re just storing it or immediately passing it along (like to a logger), map[string]any is a acceptable, if slightly clunky, shortcut. Choose wisely.