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

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:
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.
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)
}
}
}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.goitems 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 bytesResults
| Items | Format | Bytes | Encode | B / allocs | Decode | B / allocs |
|---|---|---|---|---|---|---|
| 1 | JSON | 199 | 695 ns | 528 / 9 | 2,517 ns | 912 / 24 |
| 1 | protobuf | 118 | 785 ns | 320 / 13 | 840 ns | 824 / 26 |
| 20 | JSON | 990 | 3,061 ns | 1,344 / 9 | 11,758 ns | 3,328 / 48 |
| 20 | protobuf | 574 | 1,595 ns | 768 / 13 | 2,693 ns | 3,144 / 69 |
| 500 | JSON | 21,570 | 57,590 ns | 22,110 / 9 | 234,440 ns | 46,336 / 532 |
| 500 | protobuf | 12,094 | 22,056 ns | 12,480 / 13 | 42,341 ns | 58,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
.protofile, compiled withprotocorbuf, 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 JSONnulland absent keys, and it trips people when they migrate.
When protobuf is worth it
- Service to service traffic at volume, especially with gRPC, where streaming, deadlines, a typed contract and code generation for several languages come with it.
- Large messages parsed often, where the 5 times decode saving is visible in a profile.
- Storage that you read much more than you write, where the smaller size and faster reads pay each time.
- 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.


