Building a High-Performance JSON Parser using Go Code Generation
Every few months someone on the team proposes “we should just use a faster JSON library” and benchmarks encoding/json against a dropped-in replacement, sees a 2x, and ships it. That’s fine, but it misses the point. The wins on a real API gateway don’t come from a clever decoder — they come from removing the two costs encoding/json always pays: reflection to build field mappings on every decode, and the allocation churn that falls out of it. We learned this the hard way on a billing events pipeline that ingested 40k JSON events per second, where json.Unmarshal alone was consuming 14% of wall time across the fleet.
The root problem is structural. encoding/json walks your struct with reflection: for each key in the incoming document it does a string-to-field lookup against the type, then writes the value through reflection setters. It builds no caches per type at init (it does for the struct layout via fields()), and every interface{} hop and string conversion allocates. The result for a typical 200-byte event struct is ~8 allocations per unmarshal and ~1.4µs, which is fine for a web handler doing one decode per request and brutal for anything doing decode at scale.
Code generation removes reflection from the hot path entirely. Tools like easyjson read your struct and emit concrete, monomorphized marshal/unmarshal functions that reference fields directly and parse the byte stream with hand-rolled scanners. There’s no type introspection left; the compiler knows exactly what you’re doing. The output is unglamorous and repetitive, which is exactly why you want it generated rather than written:
1
2
3
4
5
6
7
8
9
10
11
package event
//go:generate easyjson -all .
type Event struct {
ID string `json:"id"`
Kind string `json:"kind"`
Amount float64 `json:"amount"`
CreatedAt int64 `json:"created_at"`
Tags []string `json:"tags,omitempty"`
}
Run go generate ./... in your build, and easyjson writes event_easyjson.go with an UnmarshalJSON that does a direct token scan: skip whitespace, read a string literal, walk the payload key by key comparing against the known fields, and convert each value with zero reflection. Our benchmark on that exact struct was unambiguous:
- Unmarshal: 1.38µs / 8 allocs (
encoding/json) → 330ns / 1 alloc (easyjson) — 4.2x. - Marshal: 860ns / 5 allocs → 150ns / 0 allocs (with the optional unsafe fast path) — 5.7x.
- End-to-end throughput on the 40k/s pipeline: 14% of CPU gone, p99 event-processing latency down from 4.1ms to 2.2ms.
Two notes on those numbers. First, the ratio is worse for larger structs with many fields because reflection cost grows with field count; a 40-field document we proxy is nearly 6x faster generated. Second, the -unsafe flag in easyjson trades a direct string-to-[]byte conversion for the last allocation; the conversion is safe in practice because the bytes are always copied before the underlying buffer is reused, but if that sentence makes you nervous, keep it off — the 4x is still there without it.
The trade-offs are real and you should price them:
- Build step. Every struct change requires regenerating; a
go:generatecomment plus a CI check (go generate ./... && git diff --exit-code) keeps drift from shipping. It’s friction, not a tax — we’ve caught real field-reordering bugs because the generator output no longer matched the source. - Binary size. Generated code is verbose and inlines aggressively. Our parser package went from ~900KB to ~2.4MB of compiled code. On a 512MB RAM-bound sidecar this mattered; on the gateway fleet it didn’t. Measure, don’t assume.
- Maintenance surface. easyjson is a code generator you now own. It’s stable and battle-tested, but it is one more tool in the pipeline, and its generated code targets specific Go versions — pin it in
tools.goand in your CI image.
Where hand-writing beats codegen: when you control the wire format and only need a few fields. For the rate-limiter counter API we return exactly {bucket, tokens, reset_at} — three fields, high request volume. easyjson would still build a full field-map; a hand-written parser that reads the three keys in any order with strconv and skips the rest was 2x faster than the generated code, because it stops scanning after the third closing brace. Easyjson’s generated UnmarshalJSON is generic; yours can be specific.
Also worth naming: if you’re on a 2025-era Go version and the payload is a server response, not a hot ingestion path, don’t do any of this. encoding/json got faster with each release (better field matching, encoding.TextUnmarshaler fast paths), and the readability of standard json:"..." tags beats generated code for the 99% case. We scoped generated parsers to exactly the two pipelines that showed up in pprof — billing events and the ingress proxy — and left the rest on the standard library. Optimize the measured 14%, not the hypothetical 100%.
The pattern to copy: profile first, pin the generator, automate regeneration in CI, benchmark before and after with benchstat, and accept the binary-size tax only where the CPU it buys actually pays for itself.