Protobuf Tip #10: Choosing the right integer type
Use int64.
If you have been adding integer fields by instinctively adding int64 and moving on with your day, you are actually doing it right. You can stop reading here.
Protobuf has ten different integer types, so it is reasonable to wonder whether picking the wrong one matters. Mostly, it doesn’t. But there are a few differences worth knowing about.
So many types
Protobuf’s integer types fall into three families, divided up by how the values are encoded on the wire:
| Family | Types | Encoding | Bytes per value |
|---|---|---|---|
| Varint | int32, int64, uint32, uint64 | Base-128 varint | 1 to 10 |
| ZigZag varint | sint32, sint64 | Base-128 varint over a ZigZag mapping | 1 to 10 |
| Fixed-size | fixed32, fixed64, sfixed32, sfixed64 | Little-endian | 4 (32-bit) or 8 (64-bit) |
int32, int64, uint32, and uint64 all use varints, which spend fewer bytes on smaller values. Values under 128 fit in a single byte and it scales up to ten bytes on the maximum 64-bit values. Each byte sets aside one bit to mark whether another byte follows, which is how the decoder knows where the value ends.
Varints get ugly with negative numbers. A negative int64 always takes 10 bytes, even if the value is -1. Negative int32 values also take 10 bytes, even though the type only holds 4 bytes, because the encoder widens the value to 64 bits first and fills all the added bits with ones. A 4-byte type spending 10 bytes to say -1 is exactly as silly as it sounds. sint32 and sint64 were created to help with this issue.
sint32 and sint64 handle negatives sensibly with ZigZag encoding. Instead of storing the value directly, they count outward from zero, alternating sides: 0, -1, 1, -2, 2, and so on. Numbers with a magnitude near zero use fewer bytes on the wire, so -1 takes one byte instead of ten.
Fixed-size integers skip the continuation-bit nonsense. The value is always four (for 32-bit) or eight (for 64-bit) little-endian bytes. No continuation bits, no varint loop to decode or encode the values.
Varints do a little more work to save bytes. Fixed-width integers use more space so the parser can do less work. But how much CPU are we actually talking about?
But it doesn’t matter
I wanted to put some numbers on that “little more work,” so I benchmarked fields containing 1,000 integers using the standard google.golang.org/protobuf runtime. I benchmarked both marshal and unmarshal with small and large positive values, plus negative values for the signed types. The chart below sticks to the 64-bit types; the 32-bit variants use the same encodings over a smaller range.
I also ran the same 1,000 integers through encoding/json and encoding/json/v2. Since JSON is text-based, it’s going to use more space on the wire compared to Protobuf, but it’s useful here to get a familiar reference point for these numbers.
The charts below are measured in time that it takes to unmarshal a single message with a field that contains 1,000 integers. Marshal numbers are in the appendix and tell the same story.
The chart shows one type per family to keep it readable. uint64 tracks int64 closely for positive values, while fixed64 and sfixed64 use the same encoding and produce nearly identical results here.
The slowest case is under 6 microseconds for 1,000 values. sfixed64 is almost five times faster than negative int64, but we’re talking about 1.2 microseconds versus 5.8 microseconds. Even the JSON cases top out at 32 microseconds. These are very small numbers.
One exception is a 64-bit value that’s basically random, like a hash or generated ID. Varints don’t buy you much there because nearly every value is large. fixed64 uses eight bytes every time, so it can actually be both smaller and cheaper to decode.
Just use int64 and move on with your life
Outside of that, my default answer is still int64. It handles negatives, keeps small values small on the wire, and is very unlikely to be the reason your service is slow.
If your schemas are already full of int64, they’re fine. And the next time you add an integer field, you already know what to type.
Show the benchmark setup
Each benchmark uses a message with a single packed repeated field. Packed encoding writes the tag and length prefix just once, ensuring we measure integer parsing rather than tag overhead:
syntax = "proto3";
package bench.v1;
message Int64List {
repeated int64 values = 1;
}
message Sint64List {
repeated sint64 values = 1;
}
message Sfixed64List {
repeated sfixed64 values = 1;
}Each message holds 1,000 values from one of three distributions: small positive (0 to 99), large positive (2^50 to 2^50 + 999), and negative (-100 to -1). Payloads are pre-built, and b.Loop stops the compiler from optimizing the work away:
func benchmarkUnmarshal(b *testing.B, msg proto.Message) {
payload, err := proto.Marshal(msg)
if err != nil {
b.Fatal(err)
}
dst := msg.ProtoReflect().Type().New().Interface()
b.SetBytes(int64(len(payload)))
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
proto.Reset(dst)
if err := proto.Unmarshal(payload, dst); err != nil {
b.Fatal(err)
}
}
}proto.Reset zeroes dst rather than keeping its buffers, so every iteration re-grows the destination slice from nil.
The JSON cases run the same loop over a plain Go struct. This measures the standard library directly, avoiding Protobuf reflection overhead:
type jsonList struct {
Values []int64 `json:"values"`
}JSON has one integer representation, so all three Protobuf schemas are represented the exact same way in JSON.
Since encoding/json/v2 requires GOEXPERIMENT=jsonv2 in Go 1.26, that benchmark lives in a separate file tagged //go:build goexperiment.jsonv2. With the experiment enabled, I found that the encoding/json benchmark ran roughly twice as fast as it did without it. The experiment routes the v1 API through the new implementation, so both JSON rows below come from the experiment-enabled run.
Results average five independent 5-second runs on an Apple M5 Pro (darwin/arm64) using Go 1.26.5 and google.golang.org/protobuf v1.36.11:
GOEXPERIMENT=jsonv2 go test -run='^$' -bench=. -benchmem -benchtime=5s -count=5| Unmarshal 1,000 values | Small positive | Large positive | Negative | Allocations |
|---|---|---|---|---|
fixed64 | 1,131 ns | 1,121 ns | N/A | 1 |
sfixed64 | 1,160 ns | 1,206 ns | 1,208 ns | 1 |
int64 | 1,416 ns | 5,040 ns | 5,793 ns | 1 |
uint64 | 1,470 ns | 4,903 ns | N/A | 1 |
sint64 | 1,651 ns | 5,054 ns | 1,644 ns | 1 |
encoding/json/v2 | 16,503 ns | 24,691 ns | 18,084 ns | 12 |
encoding/json | 20,876 ns | 32,207 ns | 23,898 ns | 12 |
| Marshal 1,000 values | Small positive | Large positive | Negative | Allocations |
|---|---|---|---|---|
sfixed64 | 966 ns | 965 ns | 959 ns | 1 |
fixed64 | 968 ns | 966 ns | N/A | 1 |
int64 | 2,065 ns | 3,453 ns | 3,817 ns | 1 |
uint64 | 2,073 ns | 3,565 ns | N/A | 1 |
sint64 | 2,622 ns | 3,652 ns | 2,469 ns | 1 |
encoding/json | 6,280 ns | 12,889 ns | 6,447 ns | 3 |
encoding/json/v2 | 6,334 ns | 13,022 ns | 6,548 ns | 3 |
Allocation counts don’t vary across the three distributions, so they get one column. Every Protobuf case is a single allocation, whichever integer type you pick: unmarshal allocates the destination slice, marshal allocates the output buffer. The integer type changes how big that allocation is, not how many you make.