Some JSON payloads can't be fully described by a single struct. An event stream carries different event shapes under the same envelope. A gateway forwards JSON from a third party without knowing or caring what's inside. json.RawMessage is the tool for those cases: it's a []byte that the encoding/json package treats as already-encoded JSON, so you can defer decoding or pass bytes through untouched.
RawMessage Actually IsThe type is one line in the standard library:
That's it. It's a byte slice. What makes it special is that RawMessage implements both json.Marshaler and json.Unmarshaler. When the JSON encoder sees a RawMessage field, it writes those bytes verbatim instead of trying to encode the slice as a JSON array of numbers. When the decoder sees a JSON value that should populate a RawMessage field, it copies the raw bytes of that value into the slice instead of parsing them.
The result is a small piece of opaque JSON you can hold, pass around, store, or decode later when you know the shape.
The payload field never went through the JSON parser past the point of locating its boundaries. The decoder found the opening brace, scanned forward to the matching closing brace, and copied the bytes between them into envelope.Payload. The string conversion at print time just shows you the raw JSON text the slice holds.
Two things follow from this. First, RawMessage is fast for the decoder because it skips parsing the inner content. Second, the slice contains valid JSON only if the input did. The encoder will trust you to keep it that way.
Cost: Unmarshal into a RawMessage copies the slice rather than aliasing the input. That copy is usually fine, but if you decode millions of envelopes whose payloads you never inspect, you've allocated and held a lot of bytes for nothing.
On the encoding side, RawMessage.MarshalJSON returns the slice unchanged. The encoder splices those bytes into the output stream as if they were already a JSON value. That's the second superpower: you can pass JSON through your program and out the other side without ever touching its shape.
The product object came out byte-identical to what went in. No re-quoting, no field reordering, no precision loss on the price. If the supplier sends 19.99 you produce 19.99, not the closest float representation Go would have chosen on its own.
Compare that to what would happen with a plain []byte. The encoder turns a []byte into a base64-encoded JSON string, since that's the standard way to embed binary data. So this:
That base64 blob is what a downstream consumer gets if you use the wrong type. The fix is to declare the field as json.RawMessage, not []byte, whenever the bytes already hold JSON.
The encoder does call RawMessage.MarshalJSON for you, which validates nothing about the bytes you stuffed in. If you wrote "oops" into a RawMessage without quotes around it, you'll produce malformed JSON. That's covered in the validation section below.
The most common reason to use RawMessage is decoding event streams where the inner shape depends on a discriminator field. Consider an e-commerce order timeline: every event shares an id, type, and at timestamp, but the data field differs for OrderPlaced, OrderShipped, and OrderCancelled.
The envelope holds the type discriminator and a RawMessage payload. After the first decode, the type field tells you which concrete struct to unmarshal the payload into.
The two-pass decode is the point. Pass one parses the envelope, which gives you the type. Pass two parses only the payload, into a struct chosen at runtime. Without RawMessage, you'd have to either declare Payload as map[string]any and walk a generic tree by hand, or define a giant union struct with every possible field nullable. Both alternatives are awkward and error-prone.
Notice that we keep the second Unmarshal simple by handing it the already-located bytes. The decoder doesn't have to re-scan the envelope or skip the other fields; it starts directly at the payload's first byte.
Cost: Two-pass decoding means roughly two times the parser work compared with a single-shot decode. For thousands of events per second this is usually fine; for hundreds of thousands it might be worth using a streaming decoder (json.Decoder) to overlap I/O with parsing.
map[string]json.RawMessageSometimes you don't know the full shape and don't want to declare a struct just to read one field. A map[string]json.RawMessage lets you decode only the keys you care about and leave the rest as raw bytes.
The specs and reviews values come back with their original whitespace because nobody decoded them. Only the two fields needed were extracted. If the supplier adds new fields tomorrow, this code doesn't break; the unrecognized keys just land in the map with their bytes intact.
The map approach works well when the set of useful fields is small and you're tolerant of unknown fields. For a stricter contract, use a struct with explicit field names and let RawMessage carry only the fields whose shape varies.
This hybrid is what most production code looks like. The required fields are typed. The variable-shape fields stay raw until something actually needs them. Decode is fast, the type system carries the parts you depend on, and the parts you don't are still available if you change your mind.
The third common use is forwarding JSON through a service that doesn't need to understand it. A cart-update gateway might add metadata (request ID, timestamp, source) and forward the rest as-is.
The gateway never inspects the cart update body. It wraps the bytes, attaches its own fields, and sends the result onward. Using RawMessage keeps numeric precision and field order intact, which matters when the downstream service computes a signature or hash over the payload.
The body went through the gateway unchanged. The total: 39.98 stayed as 39.98 rather than becoming 39.980000000000004 from a round-trip through float64. The order of the keys in the body matches the input. If a downstream service signs the body to detect tampering, the signature still verifies.
The gateway pattern shows the encode side at work. The decode pulled the body into a RawMessage field, and the second marshal wrote those same bytes back out as a JSON value inside a larger object. No copies, no precision loss, no field reordering.
A RawMessage field defaults to a nil slice when the JSON object lacks the corresponding key. That same nil slice is what you'd get if the JSON contained "payload": null. If you need to tell those two cases apart, use a *json.RawMessage (pointer) instead of json.RawMessage (value).
With the value form, "missing" and "null" look identical. With the pointer form, the outer pointer tells you whether the key was present at all, and the inner slice tells you whether the value was JSON null or a real value. That distinction matters for PATCH-style APIs where omitting a field means "leave unchanged" and setting it to null means "clear the value."
Most code doesn't need the pointer. Use it only when the absence/null distinction is a real part of the protocol you're implementing. Otherwise it just adds an indirection and the risk of nil-pointer dereferences.
RawMessage.MarshalJSON is documented to return an error if the slice contains invalid JSON. As of Go 1.16, the encoder calls a method named Compact internally that validates the bytes before splicing them into the output. The check is fast, but it's a check: stuffing arbitrary bytes into a RawMessage and then marshaling will return an error rather than silently producing broken output.
The encoder caught the bad bytes and refused to produce output. The error message is the decoder's internal complaint, surfaced from inside the validation pass. The lesson is to treat a RawMessage field as a promise of valid JSON. If you receive bytes from somewhere untrusted and want to stash them, validate first (call json.Valid(bytes)) and reject the input early.
json.Valid is a syntactic check, not a semantic one. It tells you the bytes parse as JSON, not that they have the structure you expect. That's exactly the contract RawMessage needs.
There's a related sharp edge worth knowing about. Pre-Go 1.8, RawMessage didn't implement MarshalJSON on the value receiver; you had to use a pointer to get the pass-through behavior. Since Go 1.8 both forms work. Code that targets ancient Go versions might still declare *json.RawMessage everywhere out of habit, but on any modern version you can use the value form unless you actually need pointer semantics for the missing/null distinction.
Cost: The encoder's validation on MarshalJSON is roughly proportional to the size of the slice. For a 10 KB payload this is microseconds. For a 10 MB payload it's noticeable. If you've already validated the bytes upstream and the call shows up in a profile, you can implement a custom type that returns the bytes without re-validating, but the standard RawMessage is the right default.
Another subtle behavior shows up when a RawMessage field is nil. The encoder writes null for a nil RawMessage, not an empty string or a missing field. That's consistent with how Go's nil slices marshal in general, but it can surprise you if you forgot to populate the field.
If you want a missing body key instead of an explicit null, add the omitempty tag. Note that omitempty on a RawMessage treats both nil and the empty slice as empty, which matches what most callers expect.
That's the common pairing: declare optional RawMessage fields with omitempty so absent payloads don't pollute the output with null values that callers then have to special-case.
A RawMessage retains the bytes of every payload until the surrounding struct is garbage-collected. For small messages on a steady workload that's fine, but two patterns will burn memory:
| Pattern | Why It Hurts |
|---|---|
| Decoding a giant JSON document and keeping the whole result alive while you only care about one inner field | The RawMessage slice holds a copy of every byte you didn't bother to parse |
Batching tens of thousands of events into a slice where each event has a multi-kilobyte Payload you'll throw away after a single switch | The payloads stay in memory until the whole batch is processed |
The fix in both cases is the same: decode the payload immediately into its concrete type and drop the raw bytes. The two-pass pattern from the event-stream section works on each event in turn, so the only RawMessage alive at any moment is the one currently being processed.
json.NewDecoder reads one element from the array at a time. Each iteration creates a fresh Event whose Payload is processed and then discarded. The peak memory cost is the size of one event, not the size of the whole array. Setting ev.Payload = nil after use is belt-and-suspenders; the ev variable goes out of scope at the end of the loop body anyway.
The streaming decoder pairs well with RawMessage because you keep the polymorphic-decode pattern but bound the memory.
10 quizzes