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?

  1. 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.
  2. Type Safety: The second unmarshal step is into a properly defined struct. You get all the benefits of struct tags and compile-time checks.
  3. 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]any and then trying to unmarshal a single value from it. If you find yourself doing this, you should have used json.RawMessage at 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 the RawMessage) only checks for valid JSON, not the validity of the content within the RawMessage.
  • For slog Attributes: This is where map[string]any shines. When adding context to a log with slog, you often want to add arbitrary, dynamic key-value pairs. The slog.Any function and the LogValuer interface are your friends here for creating structured log attributes, which are essentially map[string]any under 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.