Go JSON Performance
"Marshal returns the JSON encoding of v." — it hands back a freshly allocated
[]byteon every call (encoding/json).
JSON serialization is frequently the dominant CPU and allocation cost in a Go service — but "the JSON looks slow" is a hypothesis, not a measurement. Profile first (go-perf-methodology): confirm encoding/json is actually hot in a CPU or alloc profile before changing anything. JSON correctness — struct-tag semantics, omitempty vs omitzero, exported-fields-only, the float64/precision trap, DisallowUnknownFields, custom MarshalJSON — is owned by go-json; read it first. This skill owns only the performance depth: where the cost comes from and the levers that move it.
All Go below builds clean under gofmt, go vet, and go test on Go 1.25 / 1.26.
1. Where the Cost Is — Reflection Per Call + Per-Call Allocation
Two structural costs make encoding/json v1 expensive on a hot path:
- Reflection on every call. v1 walks the value with
reflecteach time. It caches per-type field metadata, but the encode/decode traversal itself is reflective dispatch, not generated straight-line code — this is exactly what the codegen libraries (§8) and v2 (§7) attack. json.Marshalallocates a new buffer every call. "Marshal returns the JSON encoding of v" — a brand-new[]byteper invocation (encoding/json). At high request rates that allocation rate drives GC frequency, which is usually the real tail-latency lever (go-perf-methodology,go-perf-gc-tuning).
The cheapest win is almost always fewer allocations, not a faster parser. Measure allocs/op with -benchmem; an alloc-count regression (1→3 allocs/op) is a real, deterministic signal even when ns/op is noisy.
2. Reuse an Encoder/Decoder Over a Pooled Buffer
json.Marshal/json.Unmarshal are convenience wrappers for a []byte you already hold. For a stream — an HTTP body, a file, a socket — use an Encoder/Decoder instead: "An Encoder writes JSON values to an output stream" and "A Decoder reads and decodes JSON values from an input stream" (encoding/json). The Encoder "writes the JSON encoding of v to the stream" directly, so it never materializes the whole result as a separate []byte first.
When you must produce a []byte (not write to a stream), pool the backing buffer instead of letting Marshal allocate one each time — combine an Encoder with a pooled bytes.Buffer (sync.Pool mechanics: go-perf-sync-pool):
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func encode(v any) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // clear state before returning to the pool
bufPool.Put(buf)
}()
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
// Copy out: the buffer goes back to the pool and will be overwritten.
return append([]byte(nil), buf.Bytes()...), nil
}
Note Encode "elides insignificant space" but appends a trailing newline (encoding/json); trim it if a caller is byte-exact. Don't return buf.Bytes() directly — it aliases storage the pool will reuse.
3. Decode Into a Typed Struct, Not map[string]any/interface{}
Decoding into any is the single most common self-inflicted JSON slowdown. "To unmarshal JSON into an interface value, Unmarshal stores ... float64 for JSON numbers, ... map[string]any for JSON objects, ... []any for JSON arrays" (encoding/json). Every object becomes a freshly allocated map, every field value is boxed into an interface{}, and you pay a type assertion plus (often) a second conversion to read it back.
// WRONG — a map + an interface box per field, then assertions to use anything
var m map[string]any
json.Unmarshal(data, &m)
id := int64(m["id"].(float64)) // also the float64 precision trap (go-json)
// RIGHT — decode straight into the shape you need; no per-field boxing
type T struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
var t T
if err := json.Unmarshal(data, &t); err != nil {
return fmt.Errorf("decode: %w", err)
}
A typed struct lets the decoder write fields by offset instead of allocating a map and boxing each value. Reach for map[string]any only when the shape is genuinely unknown — and even then, prefer json.RawMessage (§4) so you decode subtrees lazily into concrete types. (The float64-for-every-number precision bug and UseNumber belong to go-json.)
4. json.RawMessage — Defer or Skip Decoding of Subtrees
json.RawMessage "is a raw encoded JSON value ... and can be used to delay JSON decoding or precompute a JSON encoding" (encoding/json). It captures a subtree as raw bytes, deferring (or entirely skipping) the cost of parsing it.
// Only the discriminator is parsed up front; Data stays as raw bytes
// until — and unless — a case actually needs it.
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func handle(b []byte) error {
var e Envelope
if err := json.Unmarshal(b, &e); err != nil {
return err
}
switch e.Type {
case "user": // pay to decode Data ONLY in this branch
var u User
return json.Unmarshal(e.Data, &u)
default:
return nil // large Data subtree never parsed at all
}
}
This is the idiomatic way to skip work: a field you store, forward, or rarely inspect need not be decoded into a typed structure on the hot path. On the marshal side, a precomputed RawMessage is spliced in without re-encoding.
5. Stream Large Inputs — Don't Buffer the Whole Document
For a large or unbounded payload, decoding the whole thing into one big value spikes memory; for a huge array you can process element-by-element. The streaming Decoder exposes a token API: "Token returns the next JSON token in the input stream" and "More reports whether there is another element in the current array or object being parsed" (encoding/json).
dec := json.NewDecoder(r) // r is an io.Reader; never io.ReadAll first
if _, err := dec.Token(); err != nil { // consume opening '['
return err
}
for dec.More() { // one element at a time — constant memory
var item Record
if err := dec.Decode(&item); err != nil {
return fmt.Errorf("decode element: %w", err)
}
process(item)
}
io.ReadAll(r) followed by Unmarshal loads the entire document into memory before parsing; the Decoder reads incrementally (it "introduces its own buffering" (encoding/json)). For newline-delimited JSON, just call Decode in a loop. Reader/Writer plumbing and buffer sizing live in go-perf-buffered-io.
6. Tag and omitempty Cost — Real but Secondary
Struct tags are parsed once per type and cached, so tag complexity is not a hot-path cost. omitempty does add a per-field zero-check on marshal, and dropping empty fields shrinks the output (fewer bytes to write and to transmit) — a modest win, not a primary lever. Pick omitempty/omitzero for correctness reasons (go-json owns that decision, including the zero-time.Time leak); don't add or remove them as a performance tactic without a benchmark. The dominant levers remain allocation count (§1–§3) and avoiding reflection entirely (§7–§8).
7. encoding/json/v2 + jsontext — the Go 1.25 Experiment
Go 1.25 ships an experimental redesign behind a build flag. Status, precisely:
- Opt-in and unstable. "Go 1.25 includes a new, experimental JSON implementation, which can be enabled by setting the environment variable
GOEXPERIMENT=jsonv2at build time" (Go 1.25 release notes). The package "is experimental, and not subject to the Go 1 compatibility promise. It only exists when building with theGOEXPERIMENT=jsonv2environment variable set" (encoding/json/v2). "The nature of an experiment is that the API is unstable and may change" (jsonv2 blog). Do not assume it is the default — it is not. - Two packages.
encoding/json/jsontexthandles JSON "at a syntactic level ... it does not depend on Go reflection" (anEncoder/DecoderwithWriteToken/ReadToken/WriteValue/ReadValue);encoding/json/v2builds the reflection-based marshaling on top (jsonv2 blog). - Performance. "In general, encoding performance is at parity between the implementations and decoding is substantially faster in the new one" (Go 1.25 release notes); the blog reports
Unmarshal"improvements of up to 10x" whileMarshalis "roughly at parity" (jsonv2 blog). So v2 is mostly a decode win. - v1 is reimplemented on v2 under the flag. "When the 'jsonv2' GOEXPERIMENT is enabled ... the
encoding/jsonpackage uses the new JSON implementation. Marshaling and unmarshaling behavior is unaffected, but the text of errors ... may change" (Go 1.25 release notes). SoGOEXPERIMENT=jsonv2 go test ./...exercises the new engine through your existing v1 code with no source changes. - Native streaming. v2 adds
MarshalEncode/UnmarshalDecodeover ajsontext.Encoder/Decoder, plusMarshalWrite/UnmarshalReadoverio.Writer/io.Reader, andMarshalerTo/UnmarshalerFrom(MarshalJSONTo/UnmarshalJSONFrom) so custom types stream instead of buffering (jsonv2 blog).
Practical stance: know it exists and that it is a real decode speedup, but default to v1 for production until v2 leaves GOEXPERIMENT. Its version status is owned by go-version-feature-map; v2's behavior differences (rejecting invalid UTF-8 and duplicate names, nil slice → [], case-sensitive matching) are correctness and belong to go-json.
8. Codegen / No-Reflection Libraries — Ecosystem Options
When a profile proves encoding/json is the bottleneck and v2's experiment is off the table, third-party libraries trade a build step or a dependency for less reflection. Treat their headline numbers as vendor claims to verify on your workload, not guarantees:
mailru/easyjsongenerates marshaling code: "a fast and easy way to marshal/unmarshal Go structs to/from JSON without the use of reflection," emittingMarshalJSON/UnmarshalJSON"compatible with the standardjson.Marshalerandjson.Unmarshalerinterfaces," and claims it "outperforms the standardencoding/jsonpackage by a factor of 4-5x" (easyjson). Cost: a generated_easyjson.gofile to keep in sync.json-iterator/gois "a high-performance 100% compatible drop-in replacement ofencoding/json," reporting decode35510 → 5623 ns/opand99 → 3 allocs/opon its own benchmark, while cautioning "always benchmark with your own workload" (jsoniter). No codegen, but a runtime dependency.segmentio/encoding/jsonis another drop-in alternative in the same space (verify current status before adopting).
Reach for these only after measurement (go-perf-methodology): a codegen step or extra dependency is real maintenance cost, and the standard library is usually fast enough once you stop allocating per call (§1–§5).
9. Don't
- Don't optimize JSON before profiling it. "JSON is slow" is a guess; confirm it dominates a CPU or alloc profile first (
go-perf-methodology). - Don't
json.Marshalper call on a hot path and discard the buffer each time — reuse anEncoderover a pooledbytes.Buffer(§2). - Don't decode into
map[string]any/interface{}when the shape is known — it allocates a map and boxes every field; decode into a typed struct (§3) (encoding/json). - Don't
io.ReadAlla huge body thenUnmarshal— stream withDecoder+Token/More(§5) (encoding/json). - Don't parse subtrees you don't use — capture them as
json.RawMessageand decode lazily (§4) (encoding/json). - Don't claim
encoding/json/v2is the default or stable — it is an opt-in Go 1.25 experiment behindGOEXPERIMENT=jsonv2(Go 1.25). - Don't return
buf.Bytes()from a pooled buffer — copy it out; the pool reuses that storage (§2). - Don't reach for easyjson/jsoniter on faith — measure on your own data; their numbers are vendor benchmarks (§8).
10. Routing to Related Skills
go-json— JSON correctness: struct tags,omitempty/omitzero, exported-fields-only, thefloat64precision trap andUseNumber,DisallowUnknownFields, customMarshalJSON, v2 behavior differences.go-perf-methodology— profile-first judgment: is JSON actually the bottleneck, and did the change measurably help.go-perf-sync-pool—sync.Poolmechanics for the pooled encode buffer in §2 (pointers, reset, victim cache).go-perf-strings-bytes-zerocopy—[]byte↔stringcost and zero-copy conversions around JSON payloads.go-perf-buffered-io—bufiosizing andio.Reader/io.Writerplumbing behind streaming decode/encode.go-perf-gc-tuning— why a lower allocation rate (fewer Marshals) is the dominant GC/tail-latency lever.go-version-feature-map— version floor: theencoding/json/v2experiment (1.25, opt-in).
11. Reference Files
High-frequency JSON-performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml