| name | go-perf-encoding-json |
| description | Guides fast JSON in Go — encoding/json's reflection and per-call allocation cost, reusing a pooled Encoder/Decoder over per-call Marshal, decoding into typed structs not map[string]any, json.RawMessage to skip subtrees, streaming with Decoder.Token, the json/v2 + jsontext Go 1.25 GOEXPERIMENT=jsonv2 experiment, and codegen libraries (easyjson, jsoniter). Fires on "json is slow", "speed up json marshaling", "reduce json allocations", "stream large json", "json/v2". Routes correctness to go-json, pooling to go-perf-sync-pool. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go JSON Performance
"Marshal returns the JSON encoding of v." — it hands back a freshly allocated []byte on 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
reflect each 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.Marshal allocates a new buffer every call. "Marshal returns the JSON encoding of v" — a brand-new []byte per 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()
bufPool.Put(buf)
}()
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
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.
var m map[string]any
json.Unmarshal(data, &m)
id := int64(m["id"].(float64))
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.
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":
var u User
return json.Unmarshal(e.Data, &u)
default:
return nil
}
}
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)
if _, err := dec.Token(); err != nil {
return err
}
for dec.More() {
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=jsonv2 at 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 the GOEXPERIMENT=jsonv2 environment 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/jsontext handles JSON "at a syntactic level ... it does not depend on Go reflection" (an Encoder/Decoder with WriteToken/ReadToken/WriteValue/ReadValue); encoding/json/v2 builds 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" while Marshal is "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/json package uses the new JSON implementation. Marshaling and unmarshaling behavior is unaffected, but the text of errors ... may change" (Go 1.25 release notes). So GOEXPERIMENT=jsonv2 go test ./... exercises the new engine through your existing v1 code with no source changes.
- Native streaming. v2 adds
MarshalEncode/UnmarshalDecode over a jsontext.Encoder/Decoder, plus MarshalWrite/UnmarshalRead over io.Writer/io.Reader, and MarshalerTo/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/easyjson generates marshaling code: "a fast and easy way to marshal/unmarshal Go structs to/from JSON without the use of reflection," emitting MarshalJSON/UnmarshalJSON "compatible with the standard json.Marshaler and json.Unmarshaler interfaces," and claims it "outperforms the standard encoding/json package by a factor of 4-5x" (easyjson). Cost: a generated _easyjson.go file to keep in sync.
json-iterator/go is "a high-performance 100% compatible drop-in replacement of encoding/json," reporting decode 35510 → 5623 ns/op and 99 → 3 allocs/op on its own benchmark, while cautioning "always benchmark with your own workload" (jsoniter). No codegen, but a runtime dependency.
segmentio/encoding/json is 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.Marshal per call on a hot path and discard the buffer each time — reuse an Encoder over a pooled bytes.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.ReadAll a huge body then Unmarshal — stream with Decoder + Token/More (§5) (encoding/json).
- Don't parse subtrees you don't use — capture them as
json.RawMessage and decode lazily (§4) (encoding/json).
- Don't claim
encoding/json/v2 is the default or stable — it is an opt-in Go 1.25 experiment behind GOEXPERIMENT=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, the float64 precision trap and UseNumber, DisallowUnknownFields, custom MarshalJSON, 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.Pool mechanics for the pooled encode buffer in §2 (pointers, reset, victim cache).
go-perf-strings-bytes-zerocopy — []byte↔string cost and zero-copy conversions around JSON payloads.
go-perf-buffered-io — bufio sizing and io.Reader/io.Writer plumbing 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: the encoding/json/v2 experiment (1.25, opt-in).
11. Reference Files
High-frequency JSON-performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml