Use int64.

If you have been adding integer fields to your schemas by typing int64 and moving on with your day, you have been doing it right. You can stop reading here.

Protobuf does have 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, and the family determines how the value is encoded on the wire:

FamilyTypesEncodingBytes per value
Varintint32, int64, uint32, uint64Base-128 varint1 to 10
ZigZag varintsint32, sint64Base-128 varint over a ZigZag mapping1 to 10
Fixed-sizefixed32, fixed64, sfixed32, sfixed64Little-endian4 (32-bit) or 8 (64-bit)

The 1-to-10 range mostly applies to the 64-bit varints. uint32 and sint32 cap out at 5 bytes; int32 is the odd one out because negative values can still take 10.

int32, int64, uint32, and uint64 all use varints. Seven bits of each byte hold the value and the remaining continuation bit says whether another byte follows. Values under 128 fit in a single byte. Larger values need more bytes and a few more trips through the decoding loop. The uint variants use the same wire encoding, but give you an unsigned range instead.

The value 150 encoded as an int64 varint takes 2 bytes, and each byte spends its first bit on a continuation flag

Plain varints get ugly with negative numbers. A negative int64 always takes 10 bytes, even if the value is -1. A negative int32 also takes 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.

sint32 and sint64 avoid this strange behavior with ZigZag encoding, which maps signed integers to unsigned values with the sign encoded in the low bit. Small negative numbers then stay small on the wire, so -1 takes one byte instead of ten. I tried to save you from this wire-format Protobuf trivia, but you insisted on reading further.

Fixed-size integers skip the continuation-bit nonsense. The value is always four or eight little-endian bytes. No continuation bits, no varint loop.

The value 150 stored as a fixed64 takes 8 flat little-endian bytes with no continuation flags

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.

A thousand packed integers in one field is already a pretty extreme case. A message with three scalar fields would show even less difference.

I also ran the same 1,000 integers through encoding/json and encoding/json/v2. JSON isn’t really the competition here. It just gives the chart a familiar reference point for how small these numbers are.

Below is unmarshal time, with all three groups drawn on one shared scale. Marshal numbers are in the appendix and tell the same story.

Unmarshaling 1,000 integers in Go: every 64-bit Protobuf integer type lands between 1.2 and 5.8 microseconds, while hand-written JSON takes 16 to 32 microseconds

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, leave them alone. When you add the next integer field, int64 is still where I’d start.

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 collapse to the same JSON. The wire type you agonized over stops existing the moment you leave the binary format.

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 valuesSmall positiveLarge positiveNegativeAllocations
fixed641,131 ns1,121 nsN/A1
sfixed641,160 ns1,206 ns1,208 ns1
int641,416 ns5,040 ns5,793 ns1
uint641,470 ns4,903 nsN/A1
sint641,651 ns5,054 ns1,644 ns1
encoding/json/v216,503 ns24,691 ns18,084 ns12
encoding/json20,876 ns32,207 ns23,898 ns12
Marshal 1,000 valuesSmall positiveLarge positiveNegativeAllocations
sfixed64966 ns965 ns959 ns1
fixed64968 ns966 nsN/A1
int642,065 ns3,453 ns3,817 ns1
uint642,073 ns3,565 nsN/A1
sint642,622 ns3,652 ns2,469 ns1
encoding/json6,280 ns12,889 ns6,447 ns3
encoding/json/v26,334 ns13,022 ns6,548 ns3

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.