When you need to pause the decoder
You are building a service that ingests JSON payloads from a third-party API. The payload contains a metadata field with a massive nested object. You only need the id from the top level. You unmarshal the whole thing into a struct, and suddenly your memory usage spikes because Go allocated structs for every single field in that metadata blob, even though you never touch them. Or worse, the structure of the metadata changes upstream, and your unmarshaling breaks because your struct definition no longer matches.
You need a way to tell Go: "Read the top level, but leave this one chunk alone for now."
That is what json.RawMessage is for. It lets you capture a fragment of JSON as raw bytes during the first pass, deferring the parsing work until you actually need the data. This saves CPU cycles, reduces memory allocations, and gives you flexibility when the schema is dynamic or unknown.
What json.RawMessage actually is
json.RawMessage is not a special container. It is a type alias for []byte.
type RawMessage []byte
The encoding/json package treats this type specially. When the decoder encounters a struct field of type json.RawMessage, it copies the bytes from the input into the slice and stops. It does not recurse into the data. It does not validate the inner structure. It does not allocate structs or maps for the contents. It just grabs the bytes and moves on.
Think of it like a sealed envelope in a stack of mail. You can sort the mail by the return address without opening every envelope. You only open the envelope when you actually need to read the letter inside. json.RawMessage is that envelope. It holds the raw bytes of the JSON fragment. You parse the contents only when you hand those bytes to json.Unmarshal again.
Minimal example
Here is the simplest pattern: define a struct with a json.RawMessage field, unmarshal the outer structure, and parse the raw field only when the logic demands it.
package main
import (
"encoding/json"
"fmt"
)
// Config holds the top-level data.
// Data is raw because we may not need to parse it.
type Config struct {
Name string `json:"name"`
Data json.RawMessage `json:"data"` // Holds raw bytes, defers parsing
}
func main() {
input := []byte(`{"name":"server-1","data":{"port":8080,"debug":true}}`)
var c Config
// Unmarshal top level; Data stays as []byte
err := json.Unmarshal(input, &c)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(c.Name) // prints: server-1
// Only parse Data if you actually need it
if len(c.Data) > 0 {
var details map[string]interface{}
err := json.Unmarshal(c.Data, &details)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(details)
}
}
The Data field contains the bytes [123 34 112 111 114 116 34 58 56 48 56 48 44 34 100 101 98 117 103 34 58 116 114 117 101 125]. The decoder copied those bytes and did nothing else. The second Unmarshal call happens only inside the if block, so if the code path never checks Data, the parsing cost is zero.
How the decoder handles raw messages
When json.Unmarshal runs, it walks the input byte slice and matches tokens to the struct fields. For a standard field like string or int, the decoder parses the value and writes it into the struct. For a json.RawMessage field, the decoder finds the start of the JSON value (object, array, string, number, boolean, or null), calculates the end, and copies that range into the slice.
This behavior has two important consequences.
First, memory allocation is cheaper. If the inner JSON is a 1 MB object with thousands of keys, unmarshaling it into a struct allocates a slice for the raw bytes plus thousands of struct headers, map entries, and string allocations. Using json.RawMessage allocates only the slice. The slice is a single heap allocation. The struct tree is gone.
Second, formatting is preserved. If you unmarshal a JSON object into a struct and then marshal it back, the output is canonical JSON: keys are sorted, whitespace is normalized, and numbers are formatted by Go's rules. If you use json.RawMessage, the bytes are copied as-is. When you marshal the struct back, the raw message is embedded directly. The whitespace, key order, and number formatting inside that fragment survive the round trip. This is useful for configuration files where formatting matters to humans.
Convention aside: The community standard for error handling is verbose by design. You see if err != nil { return err } everywhere. This boilerplate makes the unhappy path visible. Do not swallow errors from json.Unmarshal. If the raw message contains invalid JSON, the second unmarshal will fail, and you need to handle that failure explicitly.
Realistic example: Plugin manifest
Here is a realistic pattern for a plugin system where the core application handles the manifest structure but delegates the configuration parsing to the plugin itself.
package main
import (
"encoding/json"
"fmt"
)
// Plugin holds the manifest data.
// Config is raw because the core app doesn't know its shape.
type Plugin struct {
Name string `json:"name"`
Version string `json:"version"`
Config json.RawMessage `json:"config"` // Defers parsing of plugin-specific data
}
// LoadPlugin unmarshals the manifest and validates required fields.
// It returns the plugin with Config still as raw bytes.
func LoadPlugin(data []byte) (*Plugin, error) {
var p Plugin
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, fmt.Errorf("plugin name is required")
}
return &p, nil
}
The struct defines the known schema. The Config field captures the rest without allocating structs for unknown keys. The loader function validates the top-level constraints and returns early if something is wrong. The core application never needs to know about provider or scopes inside the config.
func main() {
raw := []byte(`{"name":"auth","version":"1.0","config":{"provider":"oauth2","scopes":["read","write"]}}`)
p, err := LoadPlugin(raw)
if err != nil {
fmt.Println("Error:", err)
return
}
// The plugin loader can now parse p.Config into its own types
fmt.Printf("Plugin %s loaded with %d bytes of config\n", p.Name, len(p.Config))
}
Usage shows the separation of concerns. The core app prints the byte length. The plugin code would take p.Config and unmarshal it into a struct that only the plugin knows about. This keeps the core application decoupled from plugin internals.
Separate concerns. The core app handles the manifest; the plugin handles its config.
Pitfalls and runtime traps
The most common mistake is treating json.RawMessage as if it is already parsed. It is just bytes. If you try to access a field on it, the compiler rejects the code with p.Config.Field undefined (type json.RawMessage has no field or method Field). You must unmarshal the raw message into a typed value before you can use it.
Another trap is the null case. json.RawMessage is a slice. If the JSON input has "data": null, the RawMessage field becomes a nil slice. If the input has "data": {}, it becomes a slice containing the bytes for an empty object. Always check len(c.Data) > 0 or handle the nil case before unmarshaling again. Unmarshaling a nil slice is safe and results in a zero value, but unmarshaling an empty object into a non-pointer struct can lead to confusing behavior if you expect data.
If you pass malformed JSON to the second unmarshal, the compiler won't catch it. You will get a runtime error like invalid character 'x' looking for beginning of value. This happens when the raw message was captured from a source that claims to be JSON but contains syntax errors. Always wrap the second unmarshal in error handling.
Convention aside: Receiver naming follows Go style. If you write a method on a struct that contains a json.RawMessage, use a short receiver name like (p *Plugin) or (c *Config). Do not use this or self. The receiver name should match the type abbreviation.
Check the length. Handle the error. RawMessage is bytes, not magic.
Decision matrix
Use json.RawMessage when you need to defer parsing to save CPU or memory, or when the structure of a field is unknown until runtime.
Use a typed struct when you know the JSON shape at compile time and want type safety and fast access.
Use map[string]interface{} when the JSON structure is dynamic and you need to inspect keys or values without defining a struct, accepting the performance cost of reflection and interface allocations.
Use json.Decoder with Token or More when you are streaming large payloads and need to process data incrementally without loading the entire object into memory.
Pick the tool that matches your knowledge of the data.