Skip to content
gohacks.dev
Performance

Faster JSON in Go With Streaming and Fewer Allocations

Make encoding/json faster without a new library: a 3 field struct decoded in 1,837 ns against 3,422 for a map, and a hand written encoder took 68 ns

14 min read
Summarize in AI
Flat illustration of a rising bar chart and a gauge, with the JSON logo.

Before replacing encoding/json with a third party library, there is a cheaper step that is often enough: use the standard library the way it is fast. Most slow JSON code in Go is slow because of its shape. It decodes into map[string]any and asserts types afterwards, parses 12 fields to read 3, loads a 55 MB array into memory to loop over it once, and builds a response with Marshal in a path where a buffer could have been reused. This article measures each of those on a 251 byte event and a 55 MB array, shows how much each change saves, and says where the standard library's limits start, which is where a library comparison picks up.

Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS arm64, encoding/json from the standard library, median of 5 runs of 150 ms, a 54.9 MB array of 200,000 events

The method is in benchmarking a Go function properly, and the allocation work is the same as reducing allocations. The wire format question, JSON against Protocol Buffers, is in Protobuf or JSON speed. The cornerstone is the Go performance guide.

Why encoding/json costs what it costs

The package works by reflection. For each type it encodes or decodes, it builds a plan the first time and caches it, then walks values with reflect on every call. Reflection is slower than compiled code, and it allocates: strings are copied out of the input, slices grow as elements arrive, maps are created, and any values box their contents. A decode also has to scan every byte of the input to find where values end, even for fields you will throw away.

Three things follow, and the rest of the article is about them. Fewer fields means less allocation, though not less scanning. A concrete type means a plan that fits and no boxing. And a smaller working set means less for the collector to deal with, because in JSON-heavy services the garbage collector often costs more than the parsing.

The lab

One event with 12 fields of mixed types (251 bytes of JSON), and a Slim struct with the three fields a consumer might actually read. The second half builds an array of 200,000 events (54.9 MB) and reads it three ways, sampling heap size every two milliseconds to find the peak.

main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"runtime"
	"sort"
	"strconv"
	"sync"
	"testing"
	"time"
)

func init() {
	testing.Init()
	flag.Set("test.benchtime", "150ms")
}

type Event struct {
	ID       int64             `json:"id"`
	Type     string            `json:"type"`
	Actor    string            `json:"actor"`
	Repo     string            `json:"repo"`
	Public   bool              `json:"public"`
	Score    float64           `json:"score"`
	Labels   []string          `json:"labels"`
	Payload  map[string]string `json:"payload"`
	Created  string            `json:"created"`
	Region   string            `json:"region"`
	Duration int               `json:"duration_ms"`
	Note     string            `json:"note"`
}

// Slim reads only the three fields a consumer needs.
type Slim struct {
	ID    int64   `json:"id"`
	Type  string  `json:"type"`
	Score float64 `json:"score"`
}

func event(i int) Event {
	return Event{ID: int64(i), Type: "push", Actor: "user" + strconv.Itoa(i%1000), Repo: "org/repo" + strconv.Itoa(i%50),
		Public: i%2 == 0, Score: float64(i%100) / 7, Labels: []string{"a", "b", "c"},
		Payload: map[string]string{"ref": "main", "size": "12"}, Created: "2000-01-01T00:00:00Z", Region: "eu",
		Duration: i % 900, Note: "a short free text note for the event"}
}

func median(f func(b *testing.B)) (float64, int64, int64) {
	const runs = 5
	xs := make([]float64, 0, runs)
	var r testing.BenchmarkResult
	for i := 0; i < runs; i++ {
		r = testing.Benchmark(f)
		xs = append(xs, float64(r.T.Nanoseconds())/float64(r.N))
	}
	sort.Float64s(xs)
	return xs[runs/2], r.AllocedBytesPerOp(), r.AllocsPerOp()
}

func row(name string, f func(b *testing.B)) {
	ns, by, al := median(f)
	fmt.Printf("  %-46s %10.0f ns %9d B/op %6d allocs\n", name, ns, by, al)
}

var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

// appendEvent writes the Slim shape by hand into a caller's buffer.
func appendSlim(dst []byte, s Slim) []byte {
	dst = append(dst, `{"id":`...)
	dst = strconv.AppendInt(dst, s.ID, 10)
	dst = append(dst, `,"type":`...)
	dst = strconv.AppendQuote(dst, s.Type)
	dst = append(dst, `,"score":`...)
	dst = strconv.AppendFloat(dst, s.Score, 'g', -1, 64)
	return append(dst, '}')
}

type peak struct {
	max  uint64
	stop chan struct{}
	done chan struct{}
}

func startPeak() *peak {
	p := &peak{stop: make(chan struct{}), done: make(chan struct{})}
	go func() {
		defer close(p.done)
		var m runtime.MemStats
		for {
			select {
			case <-p.stop:
				return
			default:
			}
			runtime.ReadMemStats(&m)
			if m.HeapAlloc > p.max {
				p.max = m.HeapAlloc
			}
			time.Sleep(2 * time.Millisecond)
		}
	}()
	return p
}

func (p *peak) end() uint64 { close(p.stop); <-p.done; return p.max }

func main() {
	one := event(7)
	data, _ := json.Marshal(one)
	fmt.Printf("one event is %d bytes of JSON\n\n", len(data))

	fmt.Println("decode one event")
	row("into map[string]any", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			var m map[string]any
			json.Unmarshal(data, &m)
		}
	})
	row("into the full Event struct (12 fields)", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			var e Event
			json.Unmarshal(data, &e)
		}
	})
	row("into Slim (3 fields, the rest skipped)", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			var e Slim
			json.Unmarshal(data, &e)
		}
	})

	fmt.Println()
	fmt.Println("encode one event")
	row("json.Marshal(event)", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			json.Marshal(one)
		}
	})
	row("pooled buffer + Encoder (reused)", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			buf := pool.Get().(*bytes.Buffer)
			buf.Reset()
			json.NewEncoder(buf).Encode(one)
			pool.Put(buf)
		}
	})
	slim := Slim{ID: 7, Type: "push", Score: 1.5}
	row("json.Marshal(Slim)", func(b *testing.B) {
		b.ReportAllocs()
		for b.Loop() {
			json.Marshal(slim)
		}
	})
	row("hand written append for Slim, reused []byte", func(b *testing.B) {
		b.ReportAllocs()
		buf := make([]byte, 0, 128)
		for b.Loop() {
			buf = appendSlim(buf[:0], slim)
		}
	})

	fmt.Println()
	const n = 200000
	var big bytes.Buffer
	big.WriteByte('[')
	for i := 0; i < n; i++ {
		if i > 0 {
			big.WriteByte(',')
		}
		b, _ := json.Marshal(event(i))
		big.Write(b)
	}
	big.WriteByte(']')
	raw := big.Bytes()
	fmt.Printf("an array of %d events, %.1f MB: read it all, or one element at a time\n", n, float64(len(raw))/1e6)

	runtime.GC()
	var before runtime.MemStats
	runtime.ReadMemStats(&before)
	pk := startPeak()
	t := time.Now()
	var all []Event
	json.Unmarshal(raw, &all)
	d1 := time.Since(t)
	m1 := pk.end()
	all = nil
	runtime.GC()

	pk = startPeak()
	t = time.Now()
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.Token()
	count := 0
	var sum float64
	for dec.More() {
		var e Event
		if err := dec.Decode(&e); err != nil && err != io.EOF {
			panic(err)
		}
		sum += e.Score
		count++
	}
	d2 := time.Since(t)
	m2 := pk.end()

	pk = startPeak()
	t = time.Now()
	dec = json.NewDecoder(bytes.NewReader(raw))
	dec.Token()
	var sum2 float64
	for dec.More() {
		var e Slim
		dec.Decode(&e)
		sum2 += e.Score
	}
	d3 := time.Since(t)
	m3 := pk.end()

	fmt.Printf("  %-46s %8v  peak heap %5d MB\n", "json.Unmarshal into []Event", d1.Round(time.Millisecond), m1>>20)
	fmt.Printf("  %-46s %8v  peak heap %5d MB   (%d events, sum %.0f)\n", "Decoder, one Event at a time", d2.Round(time.Millisecond), m2>>20, count, sum)
	fmt.Printf("  %-46s %8v  peak heap %5d MB   (sum %.0f)\n", "Decoder, one Slim at a time", d3.Round(time.Millisecond), m3>>20, sum2)
}
Text
one event is 251 bytes of JSON

decode one event
  into map[string]any                                  3422 ns      2208 B/op     64 allocs
  into the full Event struct (12 fields)               2842 ns      1008 B/op     24 allocs
  into Slim (3 fields, the rest skipped)               1837 ns       272 B/op      7 allocs

encode one event
  json.Marshal(event)                                   643 ns       560 B/op      7 allocs
  pooled buffer + Encoder (reused)                      641 ns       400 B/op      7 allocs
  json.Marshal(Slim)                                    167 ns        80 B/op      2 allocs
  hand written append for Slim, reused []byte            68 ns         0 B/op      0 allocs

an array of 200000 events, 54.9 MB: read it all, or one element at a time
  json.Unmarshal into []Event                       630ms  peak heap   296 MB
  Decoder, one Event at a time                      616ms  peak heap   155 MB   (200000 events, sum 1414286)
  Decoder, one Slim at a time                       401ms  peak heap   155 MB   (sum 1414286)

Decoding one event

TargetTimeBytesAllocations
map[string]any3,422 ns2,20864
full Event struct (12 fields)2,842 ns1,00824
Slim struct (3 fields)1,837 ns2727

A struct beats a map by 20 percent in time and 55 percent in bytes. The map version allocates a map, a boxed value for each of the 12 fields, the nested map for the payload and a slice of any for the labels: 64 allocations. The struct version allocates the strings, the slice and the nested map: 24. The time difference is smaller than the allocation difference because both paths scan the same bytes. The practical rule is that map[string]any is for JSON whose shape you do not know. When you know it, even approximately, write the struct.

A struct of the fields you read is the biggest single saving. Decoding into Slim, which names three of the twelve fields, took 1,837 ns and 7 allocations, 35 percent faster than the full struct and 46 percent faster than the map. The decoder still parses the other nine fields (it must, to find the end of each value), but it does not build the strings, slices or map for them. If a handler uses three fields of a document with fifty, declare a three field type. It also documents what the code depends on, which makes a schema change easier to reason about.

The cost of the trick is that the narrow type is a second type to keep in sync with the wire format, and it is useless if you later need to pass the whole document on. For passing on, see json.RawMessage below.

Encoding one event

MethodTimeBytesAllocations
json.Marshal(event)643 ns5607
pooled bytes.Buffer with json.NewEncoder641 ns4007
json.Marshal(Slim)167 ns802
hand written append, reused []byte68 ns00

Pooling the buffer was not the win we expected. Reusing a bytes.Buffer from a sync.Pool saved 160 of 560 bytes per call and 2 ns. json.NewEncoder allocates its own state each time, and the result of the buffer was a small part of the total. A pool earns its keep when the output is large (a 50 KB response), not for a 250 byte one (sync.Pool). It is worth the buffer for big payloads written to a network connection: json.NewEncoder(w).Encode(v) straight to the http.ResponseWriter avoids building the intermediate slice altogether.

The same observation applies to the size of the type. Encoding Slim took 167 ns against 643 ns for the full event, because there is a quarter of the data to write. If a response sends more than the client uses, the cheapest fix is to send less.

A hand written encoder can be 2.5 times faster still. appendSlim writes {"id": and the number, quoted type and float with strconv.AppendInt, AppendQuote and AppendFloat into a caller's slice, and took 68 ns with no allocations. It is the fastest thing in the table and the riskiest: you own escaping, number formatting (AppendFloat with -1 gives the shortest representation that round trips, but it does not match encoding/json for very large or small exponents) and the field names. Use it for one or two hot response shapes with golden file tests that compare its output with json.Marshal, and never for general data.

Reading a large array

ApproachTimePeak heap
json.Unmarshal into []Event630 ms296 MB
Decoder, one Event at a time616 ms155 MB
Decoder, one Slim at a time401 ms155 MB

The 54.9 MB document is held in memory in all three (it is built in the program), so the 155 MB floor includes the input and some leftover garbage from building it. What matters is the difference. Loading the whole array into a slice of structs needed another 141 MB, the size of 200,000 decoded events. Processing each element as it is decoded needed only the input and one event at a time.

The streaming loop is short:

Go
dec := json.NewDecoder(r)
dec.Token() // the opening [
for dec.More() {
	var e Event
	if err := dec.Decode(&e); err != nil {
		return err
	}
	handle(e)
}

Read from a file or a network connection instead of a byte slice and the 55 MB input is never in memory at all, so the peak is a few kilobytes plus the one event. That is the real gain for imports, exports and webhooks that carry large lists. dec.Token() consumes the opening bracket, and dec.More() reports whether another element follows.

The time barely changed for full events (616 ms against 630 ms), since decoding costs the same per element, and fell to 401 ms for Slim events, 35 percent less, from the allocations avoided. Streaming alone is a memory optimisation. The time comes from decoding less.

Other tools in the standard library

json.RawMessage is a []byte that the decoder fills with the undecoded bytes of a value. Use it for a field you will pass on or decode later depending on a type tag, such as an envelope with a type and a payload. You pay to scan it once, not to build structures. It is also the way to forward a part of a document unchanged.

json.Valid checks syntax without building anything. If the question is only whether a body is well formed, it avoids the whole decode.

Decoder.UseNumber keeps numbers as json.Number strings instead of converting to float64, which matters for correctness with large integers (IDs beyond 2 to the 53rd power lose precision as floats). It is slightly slower and the right choice when you decode into any.

Encoder.SetEscapeHTML(false) skips the escaping of <, > and & that Marshal applies by default. It makes the output smaller and slightly faster for text heavy payloads, and it is safe when the output is never embedded in HTML.

omitempty and omitzero. Dropping empty fields makes the payload smaller, which makes every later step cheaper. Go 1.24 added omitzero, which also omits zero values of structs such as time.Time, for which omitempty does nothing.

Custom MarshalJSON and UnmarshalJSON bypass reflection for a type, at the price of correctness being yours. Writing one for a hot type that allocates through an intermediate (the type alias trick, a fmt.Sprintf) is a classic way to make things slower. Write them with Append style code if you write them at all.

A worked reshaping

Take a service that receives webhook batches. Each request body is an array of events, and the handler needs the ID, the type and the score to decide which queue an event goes to, then stores the original bytes for later. The first version decoded the body into []map[string]any, looped, and stored json.Marshal of each map.

The measurements in this article predict the changes. Decoding into []Slim instead of maps removes most of the 64 allocations per event. Keeping the original bytes of each element with a json.RawMessage field in an envelope (type Item struct { Slim; Raw json.RawMessage } is not possible with embedding and a custom decoder, so decode the array into []json.RawMessage first and then decode each element into Slim) means no re-marshalling at all: the stored bytes are the bytes received. Streaming with a Decoder over the request body keeps memory flat when a batch holds 200,000 events instead of 200. The result, in the units of this lab, is 1,837 ns and 7 allocations per event instead of 3,422 ns and 64, plus a re-marshal of 643 ns that no longer happens, and a peak heap that no longer scales with the batch.

Three details from experience with changes like this. Keep a test that feeds the old and the new handler the same real payloads and compares the queue each event lands in; speed work that changes behaviour is a bug. Check the failure modes: a malformed element halfway through a stream has already been partly processed, so decide whether the handler is idempotent or buffers. And set a body size limit with http.MaxBytesReader, because streaming makes it easy to forget that an attacker can send an endless array.

Pitfalls specific to speed tricks

Narrow structs hide fields. A type with three fields will silently ignore a fourth that matters tomorrow. Add DisallowUnknownFields only where strictness is wanted; on a narrow struct it would reject every document.

Shared buffers and goroutines. A []byte reused for encoding, like appendSlim's, is not safe to share between goroutines. Keep one per goroutine or take it from a pool, and copy the bytes before handing them to something asynchronous.

Number formats differ. strconv.AppendFloat(dst, f, 'g', -1, 64) produced the same text as json.Marshal for the values in the lab, which are ordinary. Very large and very small floats use different exponent rules in encoding/json (it avoids the e+ form in a particular range), and NaN and infinity are errors in json and strings in strconv. If you hand write encoding, cover the edge cases in the test.

Escaping. AppendQuote produces Go syntax, which is not always JSON: it can emit hexadecimal escapes for bytes that are not valid UTF-8 and long Unicode forms. JSON has its own escape rules, so for arbitrary strings use json.Marshal on the string or encoding/json's appendString equivalent from a library. The lab's Type field is plain ASCII, which both agree on.

Measure the whole path. A decode that is 35 percent faster matters if decoding is 40 percent of the request. Put the change under a benchmark of the handler, not of the decoder alone, with a real payload and the allocation counts on.

What the standard library cannot do

The reflection and the allocation per decoded string are the floor for encoding/json. If a profile still shows JSON at 20 or 30 percent of CPU after the changes above, the options are a faster library (the comparison article measured 8.9 times faster decoding for one), generated encoders (easyjson, ffjson, which write the marshalling code for your types), or a different format for the hot path (Protobuf). Go 1.25 also ships an experimental encoding/json/v2 behind GOEXPERIMENT=jsonv2, which is not measured here, and which is worth watching because it comes with the language.

The order to try things

  1. Profile first (CPU profiles, allocation profiles). If JSON is under 10 percent, do nothing.
  2. Send and read less. Narrow structs, omitted fields, smaller payloads.
  3. Use concrete types and avoid map[string]any for known shapes.
  4. Stream large arrays and write responses straight to the writer.
  5. Hand write the one or two hottest encoders, with tests against Marshal.
  6. Then consider another library or format.

Checklist

  • Struct instead of map: 2,842 ns and 24 allocations against 3,422 and 64.
  • Narrow struct for the fields you use: 1,837 ns and 7 allocations.
  • Decoder for large arrays: peak heap 155 MB against 296 MB.
  • Pooled buffers only help large outputs; write to the writer directly.
  • A hand written Append encoder reached 68 ns with zero allocations; test it against Marshal.
  • Profile before and after.

The package is documented in encoding/json, and the formatting functions in strconv.

Also inAPIs
Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.