Skip to content
gohacks.dev
Performance

Protobuf or JSON Encoding Speed in Go Measured Side by Side

Protobuf against JSON in Go on one order message: 44 percent smaller, decoding 5.5 times faster at 500 items, but more allocations on decode

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

Protocol Buffers are the standard answer when JSON is too slow or too big, and the standard answer is partly right. Protobuf messages are smaller and faster to parse, and by a lot on large messages. They also need a schema, a code generator, a build step and a way to read what is on the wire, and they allocate in ways that surprise people who expect a binary format to be lighter all around. This article encodes and decodes the same order message, with 1, 20 and 500 line items, as JSON with encoding/json and as protobuf with the Go protobuf library, and reports size, time and allocations for each.

Tested with Go 1.25.6, google.golang.org/protobuf v1.36.9, protoc 29.3, encoding/json from the standard library, Apple M1 Max (10 cores, 32 GB), macOS arm64, median of 5 runs of 150 ms

It continues the JSON library comparison and faster JSON in Go, which cover how far JSON can be pushed. For gRPC, which uses protobuf as its wire format, see the gRPC series. The cornerstone is the Go performance guide.

The message

A .proto file defines an Order with an ID, customer, email, paid flag, total, a repeated Item (SKU, quantity, price) and a map of metadata:

event.protoprotobuf
syntax = "proto3";

package lab;

option go_package = "./;main";

message Item {
  string sku = 1;
  int32 qty = 2;
  double price = 3;
}

message Order {
  int64 id = 1;
  string customer = 2;
  string email = 3;
  bool paid = 4;
  double total = 5;
  repeated Item items = 6;
  map<string, string> meta = 7;
}

The JSON side uses ordinary Go structs with the same fields and names. Both sides carry exactly the same data. The script generates the Go code with protoc and the protoc-gen-go plugin pinned at v1.36.9, builds the program, runs it, and removes the generated file.

main.goGo
package main

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

	"google.golang.org/protobuf/proto"
)

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

// The JSON side uses ordinary structs with the same fields.
type jItem struct {
	SKU   string  `json:"sku"`
	Qty   int32   `json:"qty"`
	Price float64 `json:"price"`
}

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

func build(n int) (*Order, jOrder) {
	p := &Order{Id: 90210, Customer: "Ada Lovelace", Email: "[email protected]", Paid: true, Total: 1234.5,
		Meta: map[string]string{"channel": "web", "coupon": "SPRING", "region": "eu"}}
	j := jOrder{ID: 90210, Customer: "Ada Lovelace", Email: "[email protected]", Paid: true, Total: 1234.5,
		Meta: map[string]string{"channel": "web", "coupon": "SPRING", "region": "eu"}}
	for i := 0; i < n; i++ {
		sku := fmt.Sprintf("SKU-%05d", i)
		q := int32(i%5 + 1)
		pr := float64(i)*1.25 + 0.99
		p.Items = append(p.Items, &Item{Sku: sku, Qty: q, Price: pr})
		j.Items = append(j.Items, jItem{sku, q, pr})
	}
	return p, j
}

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()
}

var sinkB []byte

func main() {
	fmt.Printf("%-8s %-9s %7s | %9s %7s %6s | %9s %7s %6s\n", "items", "format", "bytes", "encode ns", "B/op", "allocs", "decode ns", "B/op", "allocs")
	for _, n := range []int{1, 20, 500} {
		p, j := build(n)
		pb, _ := proto.Marshal(p)
		jb, _ := json.Marshal(j)
		for _, f := range []struct {
			name string
			size int
			enc  func(b *testing.B)
			dec  func(b *testing.B)
		}{
			{"json", len(jb), func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					sinkB, _ = json.Marshal(j)
				}
			}, func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					var x jOrder
					json.Unmarshal(jb, &x)
				}
			}},
			{"protobuf", len(pb), func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					sinkB, _ = proto.Marshal(p)
				}
			}, func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					x := &Order{}
					proto.Unmarshal(pb, x)
				}
			}},
		} {
			en, eb, ea := median(f.enc)
			dn, db, da := median(f.dec)
			fmt.Printf("%-8d %-9s %7d | %9.0f %7d %6d | %9.0f %7d %6d\n", n, f.name, f.size, en, eb, ea, dn, db, da)
		}
	}
}
Shell
set -e
GOBIN=$PWD/bin go install google.golang.org/protobuf/cmd/[email protected]
PATH=$PWD/bin:$PATH protoc --go_out=. --go_opt=paths=source_relative event.proto
go build -o app .
./app
wc -c event.pb.go | awk '{print "generated event.pb.go is " $1 " bytes"}'
rm -rf app bin event.pb.go
Text
items    format      bytes | encode ns    B/op allocs | decode ns    B/op allocs
1        json          199 |       695     528      9 |      2517     912     24
1        protobuf      118 |       785     320     13 |       840     824     26
20       json          990 |      3061    1344      9 |     11758    3328     48
20       protobuf      574 |      1595     768     13 |      2693    3144     69
500      json        21570 |     57590   22110      9 |    234440   46336    532
500      protobuf    12094 |     22056   12480     13 |     42341   58056   1033
generated event.pb.go is 6765 bytes

Results

ItemsFormatBytesEncodeB / allocsDecodeB / allocs
1JSON199695 ns528 / 92,517 ns912 / 24
1protobuf118785 ns320 / 13840 ns824 / 26
20JSON9903,061 ns1,344 / 911,758 ns3,328 / 48
20protobuf5741,595 ns768 / 132,693 ns3,144 / 69
500JSON21,57057,590 ns22,110 / 9234,440 ns46,336 / 532
500protobuf12,09422,056 ns12,480 / 1342,341 ns58,056 / 1,033

Size: about 40 percent smaller

Protobuf was 41 percent smaller at 1 item, 42 percent at 20 and 44 percent at 500. The format carries no field names (each field is a small integer tag), writes numbers in compact binary, and needs no quotes or braces. The saving in this message is modest because the payload is mostly strings, SKUs and names that no encoding shrinks. With many numbers and short strings, the saving is larger. If the data is sent over a network, compressing JSON with gzip closes much of the gap, and compressing protobuf reduces it further, so measure the compressed sizes if that is what you send.

Decoding: 3 to 5.5 times faster

The clearest difference is on decode: 840 ns against 2,517 ns at 1 item (3.0 times), 2,693 against 11,758 at 20 (4.4 times) and 42,341 against 234,440 at 500 (5.5 times). JSON decoding scans text and reflects over every field name to find where a value goes, and converts decimal strings to numbers. A protobuf decoder reads a tag, which tells it the field and the wire type, and copies or converts the bytes. The gap grows with size because the per field work dominates and its ratio is steady.

Encoding: faster, but not at every size

At 20 and 500 items protobuf encoded 1.9 and 2.6 times faster. At 1 item it was slower (785 ns against 695 ns). Marshalling a protobuf message computes its size first and then writes into a buffer of that size, so there is a fixed cost that does not exist when the message is small. If your messages are a few hundred bytes, do not expect an encoding win from protobuf, and expect the decode win.

Allocations: not fewer

The surprising row is the decode allocations at 20 and 500 items: 69 and 1,033 for protobuf against 48 and 532 for JSON, and 58,056 bytes against 46,336 at 500 items. The reason is the shape of the generated types. A repeated Item is a []*Item, so each item is its own heap object with its own pointer, plus the strings inside it. The JSON side decoded into []jItem, a slice of values in one block. Binary does not mean lighter on the heap. The time is still lower because the allocator is not where decoding spent its time, but the collector has more objects to track, and a service that decodes thousands of these per second pays for it later.

You can reduce protobuf allocations (arena style reuse, proto.UnmarshalOptions{Merge: true} into a reused message, resetting messages in a pool), and the lab does not, in the same way that it does not pool JSON buffers. The default usage is what most code does.

What protobuf costs you

  • A schema and a generator. Every message type is declared in a .proto file, compiled with protoc or buf, and the generated code is committed or built in CI. The 6,765 byte file the script generated is for two small messages.
  • Evolution rules. Fields are identified by number. Adding a field is safe, and renumbering or changing a type is not. The discipline is manageable and it is a discipline.
  • Unreadable on the wire. A protobuf payload in a log or a browser's network tab is bytes. You need the schema and a tool (protoc --decode, buf, grpcurl) to read it. JSON can be read, pasted and edited by hand, which is a large part of why it dominates public APIs.
  • A weaker fit for browsers and public APIs. Browsers handle JSON natively. Protobuf needs a library on the client, or a gateway that converts it.
  • Defaults and presence. Proto3 scalars have no "unset" without optional, so a zero is a zero. This differs from JSON null and absent keys, and it trips people when they migrate.

When protobuf is worth it

  1. Service to service traffic at volume, especially with gRPC, where streaming, deadlines, a typed contract and code generation for several languages come with it.
  2. Large messages parsed often, where the 5 times decode saving is visible in a profile.
  3. Storage that you read much more than you write, where the smaller size and faster reads pay each time.
  4. Strict contracts between teams, where a schema reviewed in a pull request is the point.

JSON remains the better choice for public APIs, browser clients, configuration, logs and anything a human debugs by reading. If the problem is that JSON handling is slow, first try the cheaper changes in faster JSON in Go and a faster library, since 4 to 8 times is available without changing the format.

Measuring for your case

The numbers above are for one message shape on one machine. Run the same comparison on your own types: write the .proto for a real message, copy the lab and replace the order with your struct, and keep the pieces that matter to you. Compare compressed sizes if the data crosses a network, run with real payloads (a catalogue of short strings behaves differently from a table of numbers), and include the decode into the structure your code uses, since a conversion from the generated type into a domain type is a cost that the benchmark of the codec alone leaves out.

Checklist

  • Protobuf was 41 to 44 percent smaller for this order.
  • Decode was 3.0 to 5.5 times faster; encode 2.6 times faster at 500 items and slower at 1.
  • Decode allocated more objects, because repeated messages are pointers.
  • The costs are a schema, a build step and unreadable payloads.
  • Use it between services at volume; keep JSON at the edges.
  • Benchmark your own message, compressed and uncompressed.

The format is documented in the Protocol Buffers documentation and the Go API in the protobuf-go package.

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.