Track 3 / Guide 14
High-Performance JSON in Go
Optimize Go JSON through typed models, allocation benchmarks, strict decoder configuration, and compatibility testing before adopting jsoniter or Sonic.
On this page
Measure the representation before replacing the codec
JSON performance in Go depends on the target representation, allocation lifetime, and surrounding handler as well as the parser. Decoding into a typed struct can avoid generic map lookups and later type assertions. A service that decodes into a map, converts into another object, and serializes again may spend more on transformations than on the initial parse.
Start with a representative profile and identify whether CPU, allocation rate, garbage collection, or downstream I/O is the limiting factor. A faster codec cannot fix a handler waiting on a slow database or retaining every decoded request in an unbounded queue. Measure the full request path and then isolate the JSON component for a controlled benchmark.
Keep compatibility as a first-class requirement. Alternative codecs may differ in edge-case handling, supported platforms, or configuration defaults. The JSON interoperability guide provides the kinds of fixtures needed before replacement. A speed gain that changes numeric meaning, escaping, or error behavior is a protocol change, not merely an optimization.
Typed structs reduce ambiguity and follow-up work
Use explicit fields and JSON tags for stable API models. This makes the intended types visible and allows the compiler to help with application code. Distinguish optional values when absence matters; a zero value alone may not tell whether a client sent a field. Pointer fields or custom presence types can express that distinction, with an allocation and complexity tradeoff.
For generic numeric input, consider the decoder's number-preserving option rather than accepting float conversion by default. Converting later to an integer requires checking errors and range. A value that has already rounded cannot be recovered by converting the float back to an integer type. Large external identifiers are often better represented as strings in the contract.
Unknown-field rejection can catch client mistakes, but it is a compatibility decision. Enabling it on a public response reader may break additive evolution. Apply it deliberately at the appropriate boundary and include tests for expected unknown fields. Also require complete input consumption when an endpoint expects one document, because a decoder can be used for streams of values and a first successful decode does not necessarily consume the whole request.
A Go 1.22 benchmark with allocation reporting
The benchmark below compares decoding the same small record into a typed struct and a generic map. Save it as json_test.go in a new module, run go mod init example.com/jsonbench, then run go test -bench=. -benchmem -count=5. It uses only the standard library and is compatible with Go 1.22.
Both cases create a fresh destination each iteration, so the comparison does not secretly give one path a reused object and the other a new allocation. The package-level sinks prevent results from becoming irrelevant to the benchmark. SetBytes reports throughput relative to input size, while the allocation report exposes bytes and allocations per operation.
This fixture is intentionally small and should be extended with representative nested objects, arrays, escaped strings, and optional fields. Keep separate benchmarks for encoding and decoding. Do not claim zero allocation because one specialized fixture reports it under a reuse pattern; parsing general strings and building containers often requires storage. The benchmark should document exactly what is reused and which allocations remain outside the timed loop.
package jsonbench
import (
"encoding/json"
"testing"
)
type Record struct {
ID string `json:"id"`
Count int `json:"count"`
Tags []string `json:"tags"`
}
var payload = []byte(`{"id":"r1","count":7,"tags":["a","b"]}`)
var typedSink Record
var genericSink map[string]any
func BenchmarkTyped(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(payload)))
for i := 0; i < b.N; i++ {
var value Record
if err := json.Unmarshal(payload, &value); err != nil {
b.Fatal(err)
}
typedSink = value
}
}
func BenchmarkGeneric(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(payload)))
for i := 0; i < b.N; i++ {
var value map[string]any
if err := json.Unmarshal(payload, &value); err != nil {
b.Fatal(err)
}
genericSink = value
}
}Compare encoding/json, jsoniter, and Sonic fairly
Keep the same destination type, fixture, validation requirements, and reuse policy across codecs. Pin dependency versions and record the Go toolchain and architecture. A library's current release may not support the same older toolchain or platform as another release, so select a compatible version intentionally instead of copying an unversioned installation command into production.
Run the correctness corpus before performance tests. Include duplicate names, invalid Unicode behavior, number boundaries, unknown fields, custom marshaling, and malformed input. Compare acceptance and resulting values rather than assuming API similarity means identical semantics. If a configuration is intended to match the standard library, test that configuration specifically.
Account for initialization and generated or optimized execution paths separately from steady-state timing. Warmup can matter for short-lived processes, while long-running services may amortize it. Inspect support for the actual deployment architecture, operating system, and restricted execution environment. A codec that performs well on one developer machine is not automatically the best choice for a heterogeneous fleet.
Reuse buffers only with clear ownership
Reusing a destination can reduce allocation, but it changes semantics if absent fields retain previous values or slices keep references to old data. Reset objects deliberately and test alternating payload shapes, especially when one request omits a field present in the previous request. Cross-request state leakage is more serious than a small allocation saving.
Buffer pools can retain unusually large allocations long after a large request finishes. Set a policy for discarding oversized buffers instead of returning all of them to the pool. Measure steady-state resident memory after a burst of large inputs, not only allocations during a small benchmark. Pooling should improve the real workload rather than preserve its worst-case footprint indefinitely.
Never return data that aliases a buffer whose ownership has already been transferred or recycled. Unsafe zero-copy techniques can create subtle lifetime bugs even in a language with garbage collection. Use library-supported ownership contracts and avoid relying on undocumented internals. The binary codec guide makes the same point: compact bytes and fewer copies are useful only when the resulting memory ownership remains correct.
Strict decoding and streaming solve different needs
A decoder reading from an io.Reader can avoid an explicit full-body read in application code, but the size of each decoded value still matters. Decoding one enormous array into a slice builds that entire slice. Token-oriented or record-oriented processing is needed when the application wants bounded incremental state.
Apply an HTTP body limit before decoding and a clear timeout policy at the server. If only one value is allowed, attempt the appropriate end-of-input check after the first decode. Do not mistake a successful decode for complete validation of the transport body. Reject non-object roots when the endpoint contract requires an object.
Use streaming because it changes the working-set model, not because its interface looks asynchronous. Keep downstream batches and goroutines bounded. A goroutine per record can accumulate work faster than a database sink completes it. The Node parsing guide discusses the same architectural pattern from another runtime: concurrency mechanisms do not eliminate the need for admission control.
Profile CPU, allocations, and tail behavior
Collect CPU and memory profiles around a representative benchmark or load test. Identify whether time is spent parsing, allocating strings, reflecting over types, validating, or copying results. An allocation profile can reveal repeated conversions that are easier to remove than the codec itself. Keep profiling runs separate from the clean timing measurements when instrumentation overhead matters.
At service level, observe garbage-collection activity and latency under realistic concurrency. Lower allocations per operation may reduce collection pressure, but a larger retained heap can still be problematic. Measure both allocation rate and live memory. A pool can reduce the first while increasing the second.
Repeat measurements across several runs and retain raw results. Use a comparison tool or statistical analysis appropriate to the sample rather than selecting the fastest run. Record changes to compiler flags, CPU architecture, and fixture distribution. If an optimization only improves an artificial tiny payload and regresses the common request, it should not be promoted based on a headline throughput figure.
Adopt changes behind a compatibility gate
Define success before replacing the codec: preserved contract behavior, a measurable improvement in the identified bottleneck, acceptable platform support, and a maintainable dependency footprint. Keep the standard implementation as a reference in tests even if production uses another library. Differential tests are particularly valuable for malformed and boundary inputs.
Roll out with bounded observation of error categories and resource usage. Avoid logging raw requests to compare decoders. A shadow comparison can use sanitized fixtures or carefully designed in-process checks where privacy and overhead are acceptable, but it should not double production work indefinitely. Have a straightforward rollback path.
Document the chosen configuration and why it exists. Future upgrades should rerun the corpus and benchmarks rather than inherit an old speed claim as fact. Go's standard library and alternative codecs evolve, and the application's payload shape evolves too. The durable optimization is an explicit typed contract, bounded lifetime, and reproducible measurement process; library selection is one decision within that system.
Make a strict decoder helper explicit about end of input
When an HTTP endpoint expects one object, its decoder helper should combine a bounded reader, the intended unknown-field policy, and a complete-consumption check. The helper's return value should mean that the whole permitted body was accepted, not just that its first value decoded. Keep that semantic promise visible in the function name and tests.
Test a valid object followed by whitespace, a second object, and arbitrary trailing text. Also test an empty body and a root array when the contract requires an object. A decoder interface designed for streams can legitimately leave input for later calls, so the application must decide whether that capability is allowed here.
Be deliberate with custom unmarshal methods. They can introduce allocations, accept alternate representations, or bypass assumptions made by generic validation. Include them in both benchmarks and compatibility tests. A typed struct is only as strict as the behavior of its fields and custom methods.
For public APIs, return a stable error category and bounded location rather than exposing every internal decoding message verbatim. Library errors can change across versions. The client contract should not depend on the precise wording of a runtime exception.
Evaluate pooling under alternating request shapes
A reuse benchmark should alternate small, large, and sparse documents. This reveals stale fields, retained backing arrays, and oversized pooled buffers that a repeated identical payload conceals. After each decode, assert the complete expected value, including fields absent from the current request.
Separate object reuse from input reuse. Keeping a static byte slice outside the benchmark is reasonable for parser measurement, but reusing a destination object changes allocation and reset work. Report both variants if production uses both. Do not compare a reused destination in one codec with a fresh destination in another and attribute the difference entirely to the library.
Measure memory after a large-request burst followed by ordinary traffic. Pools may preserve large capacities even when current requests are small. A threshold for discarding oversized buffers can improve steady-state memory at the cost of allocating again for rare large requests. Select that threshold from observed distributions rather than a convenient round number.
Check concurrent ownership with the race detector where applicable. A pooled object must not be returned while another goroutine still reads it, and a response must not reference mutable storage that the next request will overwrite. Allocation savings are not acceptable if they weaken isolation between requests.
Finally, include reset and cleanup costs in the timed path when production pays them. A benchmark that prepares a perfectly reusable object for free can overstate the benefit. The relevant metric is useful completed work under the actual lifecycle, with correctness preserved across every reuse.
Keep benchmark fixture bytes immutable and verify their checksum when comparing results across branches. A smaller or simpler payload can make a new implementation appear faster without any actual improvement. Store representative escaped strings and nested arrays alongside the benchmark so future edits preserve the workload being measured.
Engineering Comparison
| Option | Strength | Tradeoff | Required verification |
|---|---|---|---|
| encoding/json | Standard library and broad portability | Reflection and allocation costs | Baseline profiles |
| jsoniter | Alternative compatible-style API | Dependency and edge behavior | Pinned differential tests |
| Sonic | Optimized architecture-specific paths | Platform and version requirements | Target-host benchmarks |
| Typed structs | Explicit contract | Presence modeling needed | Absent and zero fixtures |
| Generic maps | Flexible shape | Type checks and allocation | Numeric fidelity |
| Buffer reuse | Lower repeated allocation | Retention and stale state | Alternating request tests |
Zero-allocation behavior is workload-specific, not a universal property of JSON unmarshaling. Run the standard-library benchmark on Go 1.22 and pin compatible alternative versions before comparison.