Spokes.wiki Search About
Created
Updated

Protobuf wire format

How protocol-buffers turns a message into bytes. The whole design follows from one decision: the encoding stores field numbers, never field names or types, so the bytes are meaningless without the .proto that produced them. Everything the format buys — the size, the speed, the evolution rules — is paid for with that dependency.

Tag, then value

Every field is a varint tag followed by a payload. The tag packs two things:

tag = (field_number << 3) | wire_type

so the low three bits are the wire type and the rest is the field number. Three bits allows eight wire types and six exist:

ID Name Carries
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, embedded messages, packed repeated fields
3 SGROUP group start (deprecated)
4 EGROUP group end (deprecated)
5 I32 fixed32, sfixed32, float

LEN is the one that makes the format recursive: a varint length follows the tag, then that many bytes. An embedded message is just a LEN payload, which is why nesting costs nothing structural.

Varints, base 128

Integers are variable-width, one to ten bytes. Each byte gives seven payload bits plus a continuation bit in the MSB; the payloads are appended together, least-significant group first. The docs' worked example is 150, encoded as 96 01:

10010110 00000001   raw bytes
 0010110  0000001   drop the continuation bits
 0000001  0010110   reverse the groups
   → 10010110 = 128 + 16 + 4 + 2 = 150

Small numbers are cheap and large ones are not, which is why field numbers 1–15 fit in a one-byte tag and the docs push you to spend them on your hottest fields.

The negative-number trap

int32 and int64 encode negatives as two's complement widened to 64 bits, so -2 costs all ten bytes. sint32/sint64 avoid that with ZigZag, which interleaves the sign into the low bit:

sint32: (n << 1) ^ (n >> 31)
sint64: (n << 1) ^ (n >> 63)

Positive p becomes 2p, negative n becomes 2|n| - 1. Same declared range, five times the bytes if you pick the wrong one, and nothing in the type name warns you — a real footgun sitting in plain sight in a T1 doc.

Repeated fields

Since Edition 2023, repeated fields of primitive type are packed by default: one LEN record holding the concatenated values rather than a tag per element. Under protobuf-editions this is the repeated_field_encoding feature and can be set back to EXPANDED per field, which is exactly the kind of behaviour editions exist to make adjustable.

Why this belongs next to the spoke's other wire formats

clickhouse-rowbinary measured RowBinary at 2.1–3.3× faster than JSON and found developers abandoning it anyway, because the format is unforgiving about types and fails silently on 64-bit values. Protobuf's answer to that class of problem is the generated code: you never hand-write the parser, so the type mismatch is caught at compile time in your language rather than at 3am in production. yaff goes the other way and deletes the parse step entirely, keeping .proto as the schema while replacing the runtime representation with an mmap-able flat layout — a rejection of exactly the tag-and-varint scan described on this page.

protocol-buffers · protobuf-editions · grpc · clickhouse-rowbinary · yaff · developer-tooling · synthesis