Skip to content
protobuf.kmcd.dev

Binary

To see how Protobuf gets its performance, we need to look at the raw bytes; this is the physical layer of the specification.

Throughout this guide, we'll use Protoscope, a specialized human-readable language that visualizes binary Protobuf streams. It's an excellent tool for bridging the gap between raw hexadecimal bytes and structured data.

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.

Before we dig into Protobuf encoding, there are three concepts to cover when working with binary data: Endianness, bit shifting, and the bitwise OR operator.

Endianness

Endianness refers to the order in which bytes are arranged in computer memory. Systems generally use either Big-Endian (most significant byte first) or Little-Endian (least significant byte first).Protobuf uses a Little-Endian approach for most integer types.

In Little-Endian systems, the "least significant byte" (the one with the smallest numerical weight) is stored first in the sequence. When we look at Protobuf's raw binary streams, this explains why smaller pieces of a number often appear before larger pieces.

MEMORY_LAYOUT_VISUALIZATION
1. Logical Value (1,000)Binary representationHigh Byte (Most Sig)02^1502^1402^1302^1202^1102^1012^912^8Low Byte (Least Sig)12^712^612^502^412^302^202^102^02. Little-Endian Memory (Storage)Low address firstLow Byte (Stored First)12^712^612^502^412^302^202^102^0High Byte (Stored Second)02^1502^1402^1302^1202^1102^1012^912^8

When storing a multi-byte integer, a Little-Endian layout places the Least Significant Byte (Low Weight) at the lowest memory address. This is why the green part appears to come "before" the blue part in raw binary streams.

Bit Shifting

Bitwise shifting is the primary tool for Bit Packing. It allows us to manipulate individual bits within a byte, moving them to "make room" for other data.

A left shift (<<) moves every bit in a number to the left. The empty spaces on the right become zeroes. Think of it like adding a zero to the end of a base-10 number (which multiplies it by 10). In binary, shifting left by 1 multiplies the number by 2. Shifting left by 3 multiplies it by 8 (23), and creates exactly 3 empty bits on the right side.

LEFT_SHIFT_LOGIC (<< 3)
Click to Restart
Bit Shifting A Value00000001000<< 3VALUE: 1 (00000001)SHIFTED VALUE: 8 (00001000)
  00000001
      << 3
= 00001000

Bit shifting is a foundational operation for data serialization. By shifting bits to the left, we can "make room" on the right side. Protobuf uses this exact technique: it shifts the field number to the left by 3, creating 3 empty bits to store the wire type.

The OR Operation

The bitwise OR (|) is used to combine binary values. It acts like a logic gate: if either input bit is 1, the output bit is 1.

Once we've shifted bits to make room (like creating 3 empty zeros), we can use the OR operation to "glue" another value into that space.

BITWISE_MERGE_LOGIC
Click to Restart
Input A0001111000Bitwise ORInput B0000110110Result0101010101010101= 0x1A
   00011000
 | 00001010
 = 00011010

The bitwise OR operation allows us to safely combine separate values into a single number. As long as the individual values are shifted to occupy non-overlapping bit ranges (like a puzzle fitting together), they can be merged without corrupting one another.

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

💡 Silliness Warning: Because of Two's Complement, a tiny negative number like -1 requires all 10 bytes (80 bits) under standard varint encoding, while positive 1 requires only 1 byte! This is a massive 10x space overhead.

ZigZag Varint

1 Byte(s)
00000001

💡 ZigZag Scaling: Space usage is determined strictly by the absolute magnitude of the number rather than its sign. The greater the magnitude, the more bytes will be used. A small negative number like -1 takes only 1 byte!

In traditional Two's Complement encoding, the sign of a number is stored in the Most Significant Bit (MSB). Because Varints strip leading zeros, a negative number (which has a leading 1 in the MSB) is forced to act like a very large unsigned integer, requiring all 10 bytes of a 64-bit varint just to represent -1.

ZigZag encoding solves this by moving the sign bit to the Least Significant Bit (LSB). It maps positive numbers to even integers (n << 1) and negative numbers to odd integers.

By pushing the sign bit to the bottom and stripping the empty leading zeroes, Protobuf ensures that small negative numbers take just as little space as small positive numbers.

This is named ZigZag because the mapping zig-zags back and forth between positive and negative integers as you count up the encoded values: 0 maps to 0, -1 to 1, 1 to 2, -2 to 3, 2 to 4, and so on.

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. While small field numbers fit in a single byte, larger ones (16 and above) will trigger the Varint continuation logic and require additional bytes.

TAG_STRUCTURE

This visualization shows how Field Number 1 and Wire Type 2 (Varint) are combined into a single tag value of 10, which is then encoded as a Base-128 Varint.

01. Bit Shift (field << 3)

1 << 3 = 8
0
0
0
0
0
0
0
1
0
0
0
0
1
0
0
0

02. Bitwise OR (| wire_type)

8 | 2 = 10
0
0
0
0
1
0
1
0
Field Number Bits
Wire Type (3 Bits)

03. Varint Encoding (Little-Endian)

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 byte.

Varint

Type 0 (000)

Most numeric types. Variable length allows small numbers to take very little space.

int32int64uint32uint64boolenum

I64

Type 1 (001)

Fixed 64-bit values. Used when values are frequently large or need exact precision.

fixed64sfixed64double

LEN

Type 2 (010)

Length-delimited blobs. Includes a length prefix followed by the payload data.

stringbytesmessagerepeated

I32

Type 5 (101)

Fixed 32-bit values. Efficient for common hardware types.

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 (Most Significant Bit) is 0. This "continuation bit" logic allows the payload to be self-delimiting.

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

Fixed-Size Fields (Wire Type 1, 5)

For double, fixed64 (Wire Type 1), and float, fixed32 (Wire Type 5), the length is hard-coded into the specification. The decoder knows to read exactly 8 or 4 bytes respectively immediately following 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)
Protoscope Representation
// Field 2: float (value 3.14)
2: 0x40490fdb

// Field 3: double (value 80.0)
3: 0x4054000000000000

Length-Delimited (Wire Type 2)

Strings, bytes, and nested messages use Length-Delimited encoding. These fields include an explicit length byte (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
Protoscope Representation
3: {"Alice"}  // Field 3 (Length-delimited string)

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 extremely compact, but it means you cannot distinguish between "set to 0" and "not set."

The optional keyword (and oneof) reintroduces Explicit Presence. These fields are wrapped in a way that ensures they 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.

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.

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.

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.

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

The Wire-Level Language

Protoscope is a specialized tool and human-editable language designed for inspecting, debugging, and manually constructing Protocol Buffers wire format data.

Standard tools like protoc require a .proto schema to make sense of binary data. Protoscope is different: it operates at the wire level, decoding the underlying binary structure (varints, tags, and length-prefixes) using heuristics, even when the original schema is missing.

Strengths

  • +

    Schema-agnostic debugging of any Protobuf stream.

  • +

    Perfect for crafting malformed messages for security testing.

  • +

    Human-readable representation of complex binary structures.

Weaknesses

  • -

    Lossy: Cannot distinguish between int32 and uint32.

  • -

    Ambiguous: May misidentify embedded messages as strings.

  • -

    No field names: You only see numeric tags (e.g., 1:).

HOW_IT_WORKS

1. Heuristic Disassembly

Protoscope scans bytes and "guesses" types based on valid UTF-8 sequences or nested tag patterns.

2. Minimalist Syntax

1: 150 // Varint
2: "Alice" // String

3. Bi-directional

It can compile text back into binary, making it a powerful "hex editor" for Protobuf.

Try modifying the JSON data below or clicking the example buttons to see how the Protoscope output changes in real-time.

Payload Input

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
  }
}

Protoscope Output

Protoscope
PROTOSCOPE_OUTPUT

Correct input to
view stream

DEBUGGING_TIP
Protoscope is especially useful when debugging "Unknown Fields". If a client sends a field that your server's schema doesn't know about, Protoscope will still show you the data, whereas a standard JSON decoder would simply drop it.

End of Transmission

This concludes our dive into the binary format. You should now have a solid intuition for how Protobuf packs data into raw bytes, how varints work, and how the tag system structures the stream.

For exhaustive details, edge cases, and the formal specification, always refer to the official Protobuf Encoding Guide.