Skip to content
gohacks.dev
Performance

Benchmark JSON Libraries for Go on Realistic Payloads

Benchmark encoding/json, goccy/go-json and json-iterator on three payload sizes: decoding a 21 KB order took 239 µs, 27 µs and 54 µs

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

encoding/json is the library every Go service starts with, and it is also the first thing a profile blames when the service is a JSON gateway. Alternatives promise several times the speed, and a few of them deliver. They also bring their own costs: another dependency, behaviour that differs from the standard library in corners, and maintenance you do not control. This article runs encoding/json, goccy/go-json and json-iterator over the same order payload at three sizes, encoding and decoding, and shows the speed, the bytes allocated and the number of allocations, so the choice can be made with numbers and not reputation.

Tested with Go 1.25.6, goccy/go-json v0.11.2, json-iterator v1.1.12, Apple M1 Max (10 cores, 32 GB), macOS arm64, median of 5 runs of 150 ms

For techniques that speed up the standard library without a new dependency, read faster JSON in Go. The method is from benchmarking a Go function properly, and the wire format comparison with Protocol Buffers is in Protobuf or JSON speed. The cornerstone is the Go performance guide.

The payload and the libraries

An Order has scalar fields, a slice of line items (a SKU string, an integer, a float), a short slice of tags, and a map[string]string. The three sizes have 1, 20 and 500 items, which come to 266, 1,057 and 21,637 bytes of JSON. Each library encodes the struct and decodes the standard library's output back into a fresh Order. The same bytes column checks that the library's output is the same as the standard library's, or at least decodes to the same value. The libraries are used the way you would call them in a service: through Marshal and Unmarshal, with the standard library compatible configuration for json-iterator.

main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"sort"
	"testing"

	gojson "github.com/goccy/go-json"
	jsoniter "github.com/json-iterator/go"
)

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

type Tag struct {
	Name  string  `json:"name"`
	Score float64 `json:"score"`
}

type Order struct {
	ID       int64             `json:"id"`
	Customer string            `json:"customer"`
	Email    string            `json:"email"`
	Paid     bool              `json:"paid"`
	Total    float64           `json:"total"`
	Items    []Item            `json:"items"`
	Tags     []Tag             `json:"tags"`
	Meta     map[string]string `json:"meta"`
}

type Item struct {
	SKU   string  `json:"sku"`
	Qty   int     `json:"qty"`
	Price float64 `json:"price"`
}

func sample(items int) Order {
	o := Order{ID: 90210, Customer: "Ada Lovelace", Email: "[email protected]", Paid: true, Total: 1234.5,
		Tags: []Tag{{"vip", 0.93}, {"early", 0.71}}, Meta: map[string]string{"channel": "web", "coupon": "SPRING", "region": "eu"}}
	for i := 0; i < items; i++ {
		o.Items = append(o.Items, Item{SKU: fmt.Sprintf("SKU-%05d", i), Qty: i%5 + 1, Price: float64(i)*1.25 + 0.99})
	}
	return o
}

type codec struct {
	name string
	enc  func(any) ([]byte, error)
	dec  func([]byte, any) error
}

func median(f func(b *testing.B)) (ns float64, bytesOp, allocs 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()
}

var sink []byte

func main() {
	codecs := []codec{
		{"encoding/json", json.Marshal, json.Unmarshal},
		{"goccy/go-json", gojson.Marshal, gojson.Unmarshal},
		{"json-iterator", jsoniter.ConfigCompatibleWithStandardLibrary.Marshal, jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal},
	}
	for _, items := range []int{1, 20, 500} {
		o := sample(items)
		ref, _ := json.Marshal(o)
		fmt.Printf("order with %d items, %d bytes of JSON\n", items, len(ref))
		fmt.Printf("  %-14s %12s %8s %7s | %12s %8s %7s | %s\n", "library", "encode ns", "B/op", "allocs", "decode ns", "B/op", "allocs", "same bytes")
		for _, c := range codecs {
			out, _ := c.enc(o)
			same := bytes.Equal(out, ref)
			if !same {
				var a, b any
				json.Unmarshal(out, &a)
				json.Unmarshal(ref, &b)
				same = fmt.Sprint(a) == fmt.Sprint(b)
			}
			en, eb, ea := median(func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					sink, _ = c.enc(o)
				}
			})
			dn, db, da := median(func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					var x Order
					if err := c.dec(ref, &x); err != nil {
						b.Fatal(err)
					}
				}
			})
			fmt.Printf("  %-14s %12.0f %8d %7d | %12.0f %8d %7d | %v\n", c.name, en, eb, ea, dn, db, da, same)
		}
	}
}
Text
order with 1 items, 266 bytes of JSON
  library           encode ns     B/op  allocs |    decode ns     B/op  allocs | same bytes
  encoding/json           890      624       9 |         3282     1000      28 | true
  goccy/go-json           487      400       2 |          566      746       5 | true
  json-iterator          1176      952      12 |         1040      744      24 | true
order with 20 items, 1057 bytes of JSON
  library           encode ns     B/op  allocs |    decode ns     B/op  allocs | same bytes
  encoding/json          3334     1488       9 |        12540     3416      52 | true
  goccy/go-json          1929     1264       2 |         1518     1494       5 | true
  json-iterator          3683     1818      12 |         3386     3161      48 | true
order with 500 items, 21637 bytes of JSON
  library           encode ns     B/op  allocs |    decode ns     B/op  allocs | same bytes
  encoding/json         55852    22145       9 |       239048    46424     536 | true
  goccy/go-json         34402    21896       2 |        26810    23909       6 | true
  json-iterator         62443    22933      12 |        53592    45932     532 | true

Results

Encoding and decoding, in nanoseconds per operation, bytes allocated and allocations:

PayloadLibraryEncodeB / allocsDecodeB / allocs
266 Bencoding/json890624 / 93,2821,000 / 28
266 Bgoccy/go-json487400 / 2566746 / 5
266 Bjson-iterator1,176952 / 121,040744 / 24
1,057 Bencoding/json3,3341,488 / 912,5403,416 / 52
1,057 Bgoccy/go-json1,9291,264 / 21,5181,494 / 5
1,057 Bjson-iterator3,6831,818 / 123,3863,161 / 48
21,637 Bencoding/json55,85222,145 / 9239,04846,424 / 536
21,637 Bgoccy/go-json34,40221,896 / 226,81023,909 / 6
21,637 Bjson-iterator62,44322,933 / 1253,59245,932 / 532

Decoding is where the libraries differ

On the largest payload goccy decoded in 26.8 µs against 239.0 µs for the standard library, 8.9 times faster, while allocating 6 times and not 536. json-iterator was 4.5 times faster than the standard library (53.6 µs) with nearly the same number of allocations as it. The standard library's decoder allocates for each string it produces, and the list of 500 items grows by appending, which is where most of its 536 allocations come from. goccy decodes into the destination with fewer intermediate objects. The time follows the allocations: the standard library spent most of its 239 µs on allocating and on the garbage collector's share of that.

The gap held on the smallest message: 3,282 ns against 566 ns, 5.8 times. The absolute difference is only 2.7 µs a call, which matters for a service handling 50,000 messages a second (a saving of 135 ms of CPU every second) and not at all for one handling 50.

Encoding gains less

goccy encoded the large payload in 34.4 µs against 55.9 µs, 1.6 times faster, and the one item payload 1.8 times faster. Encoding in the standard library is already reasonable: 9 allocations to produce 21 KB. json-iterator was slower than the standard library at encoding on all three sizes (62.4 µs against 55.9 µs on the large one), so replacing the encoder with it would have made things worse. Speed claims for a library are claims for the operation that benchmark chose, so check yours.

Bytes allocated

Encoding allocated about the output size in all three, since the result has to exist. Decoding of the large payload allocated 23.9 KB for goccy and 46.4 KB for the standard library, and the allocation count is the bigger factor for the collector.

What you give up

Compatibility is a property of the payload. All three printed true for equivalence here, with ordinary fields, a map and nested slices. They differ at the edges: how omitempty treats certain types, how embedded structs with conflicting tags resolve, how invalid UTF-8 is handled, what happens with json.Number, the order of map keys, escaping of HTML characters, and the exact text of error messages. A service that returns errors to clients, or signs a canonical encoding, needs round trip and golden file tests before a switch.

Another dependency. goccy and json-iterator are community libraries, and json-iterator in particular has had issues with new Go versions in the past because of its use of unsafe and runtime internals. Pin the version, run your tests against each Go upgrade, and check the project's recent activity before you depend on it. The standard library is maintained with the language.

Different failure modes. A reflection heavy library that touches runtime internals can crash a process where the standard library would have returned an error. This is rare and also the kind of failure that takes down a service at 3 a.m.

The standard library is changing. Go 1.25 ships an experimental encoding/json/v2 behind GOEXPERIMENT=jsonv2, with a faster decoder and different semantics in places. It was not measured here. If you are choosing a JSON path for a service you will run for years, watch it before binding to a third party library.

When it is worth switching

  1. Profile first. If JSON encoding and decoding are under about 10 percent of CPU in a CPU profile, an 8 times faster decoder saves less than 9 percent (CPU profiles).
  2. Reduce the work. Decode only what you need, stream large arrays, reuse buffers, and avoid interface{} targets (faster JSON).
  3. Try goccy behind an interface in one hot path, with tests that compare its output against the standard library's on production samples.
  4. Keep the standard library on boundaries where behaviour must match exactly, such as webhook signatures and public error bodies.
  5. Re-measure after Go upgrades. The standard library gets faster, and the gap in this table is a snapshot.

Checklist

  • Decoding is where alternatives gain the most (8.9 times for goccy on 21 KB).
  • Encoding gains are modest (1.6 times) and json-iterator lost.
  • Allocation counts explain the speed: 536 against 6 on the large decode.
  • Profile before switching, and test equivalence on real payloads.
  • Pin versions, and watch encoding/json/v2.

The libraries are at goccy/go-json and json-iterator/go, and the standard package is described in the encoding/json documentation.

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