# Go Perf Encoding JSON

> 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.

- Skill: `ctoth/go-perf-encoding-json` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-encoding-json`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-encoding-json/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-encoding-json

---


# Go JSON Performance

> "Marshal returns the JSON encoding of v." — it hands back a **freshly allocated** `[]byte` on every call ([encoding/json](https://pkg.go.dev/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:

1. **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.
2. **`json.Marshal` allocates a new buffer every call.** "Marshal returns the JSON encoding of v" — a brand-new `[]byte` per invocation ([encoding/json](https://pkg.go.dev/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](https://pkg.go.dev/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`):

```go
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](https://pkg.go.dev/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](https://pkg.go.dev/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.

```go
// 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](https://pkg.go.dev/encoding/json)). It captures a subtree as raw bytes, deferring (or entirely skipping) the cost of parsing it.

```go
// 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](https://pkg.go.dev/encoding/json)).

```go
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](https://pkg.go.dev/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](https://go.dev/doc/go1.25)). 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](https://pkg.go.dev/encoding/json/v2)). "The nature of an experiment is that the API is unstable and may change" ([jsonv2 blog](https://go.dev/blog/jsonv2-exp)). **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](https://go.dev/blog/jsonv2-exp)).
- **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](https://go.dev/doc/go1.25)); the blog reports `Unmarshal` "improvements of up to 10x" while `Marshal` is "roughly at parity" ([jsonv2 blog](https://go.dev/blog/jsonv2-exp)). 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](https://go.dev/doc/go1.25)). 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](https://go.dev/blog/jsonv2-exp)).

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](https://github.com/mailru/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](https://github.com/json-iterator/go)). 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](https://pkg.go.dev/encoding/json)).
- **Don't `io.ReadAll` a huge body then `Unmarshal`** — stream with `Decoder` + `Token`/`More` (§5) ([encoding/json](https://pkg.go.dev/encoding/json)).
- **Don't parse subtrees you don't use** — capture them as `json.RawMessage` and decode lazily (§4) ([encoding/json](https://pkg.go.dev/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](https://go.dev/doc/go1.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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

