Skip to content

The schema is the contract; the wire format is how data that honors it actually travels. This section digs into the raw bytes: the physical layer of the specification.

This guide should help explain how the Protobuf encoding works, but if you have questions about edge cases or specifics that we don't cover, refer to the official encoding guide.

Protobuf's encoding leans on three bit-level concepts: byte order, bit shifting, and bitwise OR. If you can already read x << 3 | y, skip ahead. If not, here is the short version.

Endianness

Multi-byte numbers can be laid out smallest byte first (little-endian) or largest byte first (big-endian). Protobuf's fixed-width types (fixed32, fixed64, float, double) are little-endian, which is why the low bytes of a number appear first in the raw stream.

00000011111010001110100000000011

Bit Shifting

A left shift (<<) moves every bit left and fills the gap with zeroes: shifting by 3 multiplies by 8 and leaves 3 empty bits on the right. Protobuf uses this to build the field tag, shifting the field number left by 3 to make room for the wire type.

0000000100000001000

Bitwise OR

OR (|) merges two numbers: an output bit is 1 if either input bit is 1. After a shift clears space, OR glues another value into it. As long as the two values occupy non-overlapping bit ranges, neither corrupts the other.

000110000000101000011010

Varints are the fundamental building block of Protobuf efficiency, allowing integers to occupy only as many bytes as necessary.

Standard integers in memory take 4 or 8 bytes regardless of their value. Varints use Base-128 Serialization to represent smaller numbers with fewer bytes.

Each byte in a varint, except the last byte, has the most significant bit (MSB) set to 1. This acts as a continuation flag, telling the decoder "more bytes are coming."

The lower 7 bits of each byte store the data in groups of 7, least significant group first. This means Protobuf uses a Little-Endian approach even at the bit-group level.

Varint encoding steps
1

Chunk Data

Split the number into 7-bit groups. Standard bytes are 8 bits, but we reserve the top bit (MSB) as a "continuation bit".

Group 1
0000001
Group 0
0010110
2

Reverse & Add MSB Flag

The groups are written in Little-Endian order (least significant group first). Set the MSB to 1 for all bytes except the last one.

Byte 0
10010110
MSBDATA
0x96
Byte 1
00000001
MSBDATA
0x01

Standard Varints are great for positive numbers, but they are highly inefficient for negative ones. ZigZag encoding fixes this.

ZigZag transformation
Original Signed
-1
(n << 1) ^ (n >> 63)
Encoded Unsigned
1

Standard Varint (Two's Complement)

10 Byte(s)
11111111
11111111
11111111
11111111
11111111
11111111
11111111
11111111
11111111
00000001

As a plain varint, -1 takes all 10 bytes: a negative number has no leading zeros to drop.

ZigZag Varint

1 Byte(s)
00000001

After ZigZag, size depends on the number's magnitude rather than its sign: -1 encodes in a single byte, and the byte count grows only as the value moves away from zero.

Two's complement stores the sign in the most significant bit, which means every negative number has its high bits set. That is bad news for varints, which save space by dropping leading zeros: a negative number has none to drop. Even -1 takes the full 10 bytes of a 64-bit varint.

ZigZag encoding moves the sign to the least significant bit instead. Positive numbers map to even integers (n << 1) and negative numbers to odd ones.

With the sign at the bottom, small negative numbers have leading zeros again and encode as compactly as small positive ones.

The name comes from how the mapping alternates between positive and negative as the encoded value counts up: 0 maps to 0, -1 to 1, 1 to 2, -2 to 3, 2 to 4, and so on.

ZigZag integers allow for more efficient storage of small negative numbers.

Every field in a Protobuf message is prefixed by a Tag. This tag is the only reason the decoder knows which field it's currently processing and how to interpret the bytes that follow.

Tag Composition

A tag is a single Varint that combines two pieces of information:

  • Field Number (bits 3 through N)
  • Wire Type (the bottom 3 bits)

The formula for the tag value is (field_number << 3) | wire_type. Small field numbers fit in a single byte; larger ones (16 and above) spill into additional bytes under the same continuation rule varints always follow.

Tag structure

A tag packs two facts into a single number: the field number and the wire type. The lowest three bits hold the wire type, and everything above them holds the field number.

One number, two parts

(1 << 3) | 2 = 10
0
0
0
0
1
Field number = 1
0
1
0
Wire type = 2 (LEN)

Encoded as a varint

101 Byte(s)
Byte 00x0A
0
0
0
0
1
0
1
0
MSB7-Bit Value Chunk

Every field on the wire is wrapped in an "envelope" that tells the decoder two things: which field number it is, and how to read the payload. These are packed into a single tag value, encoded as a varint.

Wire typeProtobuf types
0 (Varint)
int32int64uint32uint64boolenum
1 (I64)
fixed64sfixed64double
2 (LEN)
stringbytesmessagealso used for packed repeated scalars
3 (SGROUP)group start (deprecated)
4 (EGROUP)group end (deprecated)
5 (I32)
fixed32sfixed32float

Varint Fields (Wire Type 0)

Most numeric types use Wire Type 0. The length is implicit because the decoder reads bytes one by one until it finds a byte where the MSB is 0. That's the same continuation-bit rule from the varint section earlier, doing the delimiting on its own.

messageUser {int32age =1;}Schemaage:150Data+Encoded Payload08Tag96 01Value (Varint 150)00001000Field 1Type 010010110 00000001MSB=1 (Cont.)MSB=0 (End)

Fixed-Size Fields (Wire Type 1, 5)

Some types have a known size, so nothing needs to be measured. Wire Type 1 is always 8 bytes, used by double and fixed64. Wire Type 5 is always 4 bytes, used by float and fixed32. The decoder simply reads that many bytes after the tag.

messageUser {floatheight =2;doubleweight =3;}Schemaheight:3.14weight:80.0Data+Encoded Payload15Tagdb 0f 49 40Value (3.14 float, Little-Endian)00010101Field 2Type 511011011 0000111101001001 01000000Fixed 32-bit (4 Bytes)19Tag00 00 00 00 00 00 54 40Value (80.0 double, Little-Endian)00011001Field 3Type 100000000 00000000 00000000 0000000000000000 00000000 01010100 01000000Fixed 64-bit (8 Bytes)

Length-Delimited (Wire Type 2)

string, bytes, and nested message fields use Length-Delimited encoding. These fields include an explicit length prefix (encoded as a varint) immediately after the tag, telling the decoder exactly how many subsequent bytes belong to this field.

messageUser {stringname =3;}Schemaname:"Alice"Data+Encoded Payload1aTag05Len41 6c 69 63 65"Alice"00011010Field 3Type 200000101Length 501000001 0110110001101001 01100011 01100101"Alice" ASCII

Packed Repeated Fields

Repeated fields of primitive types use a specialized encoding to avoid repeating the field tag for every element.

NON-PACKED (OLD WAY)
Schema
repeated int32 ids = 1 [packed=false];
Values
[1, 2, 3]
Wire Layout
08
Tag
01
Val
08
Tag
02
Val
08
Tag
03
Val

Each element repeats the field tag. High overhead for many small elements.

PACKED (DEFAULT IN PROTO3)
Schema
repeated int32 ids = 1;
Values
[1, 2, 3]
Wire Layout
0a
Tag
03
Len
01
Data
02
Data
03
Data

Elements are concatenated into a single length-delimited record. One tag for the whole set.

Maps are not a native wire-level primitive. Instead, they are syntactic sugar for a repeated message.

map<string, int32> items = 1;
Is equivalent to:
message Entry {
  string key = 1;
  int32 value = 2;
}
repeated Entry items = 1;

One of the biggest space savers in Protobuf is the omission of default values.

STANDARD PROTO3 FIELD
Schema Definition
int32 count = 1;
Assigned Value
0 (Default)
OMITTED
Wire Representation
(Zero Bytes)

Standard fields are omitted from the wire if they hold the default value.

OPTIONAL PROTO3 FIELD
Schema Definition
optional int32 count = 1;
Assigned Value
0 (Default)
SERIALIZED
Wire Representation
08
Tag
00
Data

Optional fields track explicit presence. They are serialized even if set to 0.

In Proto3, fields set to their default value (0, empty string, false) are not serialized at all. This makes the wire format compact, but it means you cannot distinguish between "set to 0" and "not set."

The optional keyword (and oneof) reintroduces Explicit Presence. Once explicitly set, these fields are written to the wire even if their value is the default, allowing for has_field() checks.

Note on Editions: With the introduction of Protobuf Editions, the strict boundaries between Proto2 and Proto3 behavior have been removed. You can now explicitly configure whether fields use implicit or explicit presence via features like features.field_presence = EXPLICIT;, giving you granular control over serialization size versus field state tracking.

When multiple fields are sent together, Protobuf concatenates them into one binary stream. The decoder does not need separators between fields; each field tells the decoder how many bytes to consume before moving on.

  1. 01

    Read the tag

    The first varint in each field is the tag. Its lower three bits identify the wire type, and the remaining bits identify the field number.

  2. 02

    Choose the payload rule

    The wire type tells the decoder how to find the payload boundary: varint continuation bits, a fixed 4 or 8 byte width, or a length-delimited size prefix.

  3. 03

    Consume that field

    Once the payload length is known, the decoder consumes exactly those bytes, maps them to the schema field when possible, and advances its cursor.

  4. 04

    Repeat until EOF

    The stream has no outer field count. Parsing continues from the next byte and stops only when there are no bytes left to process.

Protobuf FieldsBinary Stream (Hex)string name = 1Value: "Alice"0aTag05Len41 6c 69 63 65Dataint32 id = 2Value: 15010Tag96 01Datafloat score = 3Value: 95.51dTag00 00 bf 42DataField TagLength PrefixValue Payload

Try modifying the JSON data below or clicking the example buttons to see how the binary stream changes in real-time. Click any segment in the encoded stream to inspect how its tag, length, and payload were parsed.

JSON input
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Hiro Protagonist",
  "email": "hiro@metaverse.com",
  "age": 24,
  "heightCm": 175.5,
  "weightKg": 70.2,
  "role": 2,
  "birthDate": {
    "year": 1992,
    "month": 5,
    "day": 22
  }
}
Encoded stream (hex)

No Message Schema

Please define a valid message schema to begin encoding