The JSON tax in Go
You are building an API endpoint that returns a list of items. The code works. json.Marshal turns the slice into bytes. You send the response. Under light load, everything is fast. Then you hit load testing with thousands of concurrent requests. The latency spikes. The memory graph looks like a sawtooth wave. The garbage collector wakes up every few milliseconds to clean up temporary byte slices. Your p99 latency jumps from 5ms to 50ms.
The bottleneck is not your business logic. It is the JSON serialization. Every time you turn a struct into bytes or bytes into a struct, Go allocates memory. In a hot path, those allocations add up fast. The standard encoding/json package is correct and safe, but it is not free. It uses reflection to inspect types at runtime, and it creates new slices for every call. Optimization means reducing the cost of that reflection and minimizing the memory churn.
Why JSON allocates
JSON encoding in Go relies on reflection. The encoding/json package inspects your struct at runtime to find field names, types, and tags. Reflection is flexible but expensive. It prevents the compiler from optimizing the code path. Every call to Marshal or Unmarshal triggers this inspection. The package also allocates intermediate strings and byte slices during the process.
Think of json.Marshal like a copy machine. You hand it a document. It prints a new copy on fresh paper. If you need ten copies, you get ten sheets of paper. If you only need to transmit the document, you do not need the copies. Streaming encoders and decoders work like a direct line. They read the document and transmit it without printing intermediate copies. Buffer reuse works like a reusable shipping box. You pack the item, ship it, empty the box, and pack the next item. You do not throw away the box after every shipment.
The basics: Marshal and Unmarshal
The simplest way to handle JSON is json.Marshal and json.Unmarshal. These functions are convenient for small payloads. They return a new []byte or populate a struct. The convenience comes with an allocation cost.
Here is the standard pattern. It allocates a new slice every time.
package main
import (
"encoding/json"
"fmt"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
item := Item{ID: 1, Name: "Widget"}
// Marshal returns a new []byte. The caller owns the memory.
// This allocation happens on every call.
data, err := json.Marshal(item)
if err != nil {
fmt.Println(err)
return
}
// Unmarshal reads bytes into a struct.
// It allocates strings for map keys and slice elements.
var decoded Item
if err := json.Unmarshal(data, &decoded); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", decoded)
}
Marshal returns a []byte. The slice points to a new backing array. If you call Marshal in a loop, you are allocating N times. Unmarshal allocates memory for the target struct's fields. If the struct contains slices or maps, Unmarshal allocates those collections. The decoder needs a pointer to the target value. Passing a value instead of a pointer causes a runtime error. The compiler rejects the program with json: Unmarshal(non-pointer Item) if you forget the address-of operator.
Go convention requires explicit error handling. You will see if err != nil after every JSON call. This verbosity is intentional. It forces you to handle the failure case where the input is malformed or the types do not match. Struct tags use backticks. json:"id" maps the field to the JSON key. The tag format is strict. A typo in a tag will not fail compilation. It will just fail at runtime with a missing field or a silent omission. Public fields start with a capital letter. encoding/json only sees exported fields. If your field is lowercase, it will not appear in the JSON. The compiler will not warn you about this.
Streaming with Encoder and Decoder
When you are writing to a network connection or reading from a stream, avoid Marshal and Unmarshal. Use json.NewEncoder and json.NewDecoder. These types implement io.Writer and io.Reader patterns. They buffer data internally and write directly to the destination. They avoid creating the full byte slice in memory.
Here is how to stream JSON to a writer. The encoder reuses internal buffers.
package main
import (
"encoding/json"
"io"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
// writeItems streams items to the writer without allocating a full []byte.
func writeItems(w io.Writer, items []Item) error {
// NewEncoder wraps the writer. It buffers internally to reduce syscalls.
enc := json.NewEncoder(w)
for _, item := range items {
// Encode writes the JSON followed by a newline.
// The encoder reuses its internal buffer across calls.
if err := enc.Encode(item); err != nil {
return err
}
}
return nil
}
Encode writes the JSON representation followed by a newline. This makes it easy to stream multiple values. The encoder buffers the output. It flushes the buffer when it is full or when the writer closes. This reduces the number of system calls. If you are writing to a file or a TCP connection, this is significantly faster than Marshal followed by Write.
Reading streams works similarly. The decoder buffers input and parses incrementally.
package main
import (
"encoding/json"
"io"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
// readItems streams items from the reader.
func readItems(r io.Reader) ([]Item, error) {
// NewDecoder wraps the reader. It buffers input.
dec := json.NewDecoder(r)
// Decode reads the opening bracket of the array.
var token json.Token
if err := dec.Decode(&token); err != nil {
return nil, err
}
var items []Item
// More returns true if there is another element in the array.
// It does not consume the element.
for dec.More() {
var item Item
// Decode unmarshals the next element into item.
if err := dec.Decode(&item); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
More checks if the array has more elements without consuming them. This pattern is essential for streaming large arrays. If you call Unmarshal on a huge JSON array, the entire array must fit in memory. The decoder processes one element at a time. It keeps memory usage constant regardless of the payload size. If the JSON structure does not match the struct, the decoder returns an error like json: cannot unmarshal object into Go value of type string. Check your struct tags and field types.
Buffer reuse and sync.Pool
If you must use Marshal, reuse buffers. A bytes.Buffer holds a []byte backing array. Calling Reset clears the content but keeps the capacity. Subsequent writes reuse the memory. This eliminates allocations for the buffer itself.
Here is how to reuse a buffer for compacting JSON.
package main
import (
"bytes"
"encoding/json"
)
// buf is reused across calls to avoid allocations.
var buf bytes.Buffer
// compactJSON returns minified JSON.
func compactJSON(data []byte) ([]byte, error) {
// Reset clears the buffer without freeing the underlying array.
// The capacity is preserved for the next call.
buf.Reset()
// Compact writes minified JSON to the buffer.
// It removes whitespace and newlines.
if err := json.Compact(&buf, data); err != nil {
return nil, err
}
// Bytes returns a copy of the buffer contents.
// The caller gets a new slice, but the buffer memory is reused.
return buf.Bytes(), nil
}
Reset is the key. It sets the length to zero but leaves the capacity intact. The next write starts from index zero and overwrites the old data. If the new data fits in the existing capacity, no allocation occurs. This pattern works for Marshal too. Pass &buf to json.NewEncoder(&buf) and call buf.Reset() before each encode.
In a concurrent web server, each request runs in a goroutine. If every goroutine allocates a buffer, you pressure the allocator. sync.Pool lets goroutines share buffers. When a goroutine finishes, it returns the buffer to the pool. The next goroutine grabs it. This reduces allocations across the whole process.
package main
import (
"bytes"
"sync"
)
// bufPool stores reusable buffers.
var bufPool = sync.Pool{
// New creates a fresh buffer if the pool is empty.
New: func() interface{} {
return new(bytes.Buffer)
},
}
// getBuffer retrieves a buffer from the pool.
func getBuffer() *bytes.Buffer {
// Get returns an interface{}. Type assertion is safe because
// New always returns *bytes.Buffer.
return bufPool.Get().(*bytes.Buffer)
}
// putBuffer returns a buffer to the pool.
func putBuffer(buf *bytes.Buffer) {
// Reset clears the buffer before returning it.
buf.Reset()
bufPool.Put(buf)
}
sync.Pool is designed for temporary objects. The pool may discard buffers at any time to reduce memory pressure. Never rely on the pool holding a buffer. Always treat Get as potentially returning a fresh object. Reset the buffer after use and return it. This pattern is common in high-performance Go servers.
Pitfalls and errors
JSON optimization introduces subtle bugs if you are not careful. The compiler catches some errors, but others appear at runtime.
Passing a non-pointer to Decode causes a panic. The compiler rejects the program with json: Unmarshal(non-pointer Item). Always pass the address of the target value. The decoder needs to modify the value.
If the JSON contains a value that does not match the Go type, the decoder returns an error. You might see json: cannot unmarshal number into Go struct field Item.Name of type string. This happens when the API returns a number but your struct expects a string. Fix the struct definition or use json.Number to handle flexible types.
Buffer reuse requires discipline. If you return buf.Bytes() from a function and then reset the buffer, the returned slice points to the same backing array. The next write will corrupt the data. Always copy the data if you need to hold it longer than the buffer's lifetime. bytes.Clone or append([]byte(nil), buf.Bytes()...) creates a copy.
json.RawMessage is a type alias for []byte. It defers parsing. You can decode a field as RawMessage to get the raw JSON bytes without parsing the nested structure. This is useful when you only need to inspect a field or pass it through. If you try to unmarshal into a RawMessage and the input is not valid JSON, you get json: invalid character 'x' looking for beginning of value. Validate the input if necessary.
Allocations are the enemy of latency. Reuse buffers and stream when possible.
Decision matrix
Choose the right tool based on your payload size and usage pattern.
Use json.Marshal when the payload is small and you need a []byte immediately for a single operation. Use json.Unmarshal when reading a small, complete JSON document into a struct. Use json.NewEncoder when writing to a network connection, file, or any io.Writer. Use json.NewDecoder when reading from a stream, large file, or any io.Reader. Use a reused bytes.Buffer when calling Marshal in a tight loop or hot path. Use sync.Pool for buffers in concurrent handlers to share memory across goroutines. Use json.RawMessage when you need to inspect a field without parsing the entire nested structure. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
The decoder is your friend for large streams. Marshal when it fits in a register.