TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Protobuf Wire Format: 21 Bytes One by One

Status: Draft (2026-10-17), empirically measured. All bytes were directly encoded using python3 (3.9, standard library only). Cross-checking by serializing the same message with the official protobuf library was skipped because it requires downloading a package (unmeasured, requirements: protobuf Python package and protoc). The rules were verified using protobuf.dev Encoding documentationand the proto3 Language Guide(accessed 2026-10-17).

Table of Contents

1. Intuition: Field Numbers Instead of Field Names

JSON writes {"rallyId":150} like field names every time . It is easy to read, but the same names repeat across messages. Protobuf assumes that both sides already have the contract (.proto) and only writes field numbers and valuesinto the bytes. 「Field 1: 150」. Names are in the contract, so they don't need to be sent.

Two things follow from this:

2. Diagram: 21 Bytes of a Rally

We populate one message from the contract in Part 1. rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.

Rally 메시지 21바이트를 한 칸씩 08 태그 96 01 12 태그 06 ed 8b b0 ed 8b b0 1a 태그 04 03 8e 02 01 20 태그 01 28 태그 03 읽는 법 필드 1 rally_id 태그 08 · 150=96 01 필드 2 server 태그 12 · 길이 06 · UTF-8 필드 3 hits 태그 1a · 길이 04 · 3,270,1 필드 4 won 태그 20 · 1 필드 5 delta 태그 28 · -2→3 태그 = (필드 번호 << 3) | 와이어 타입. 08 = 1번, varint 같은 값을 다른 형식으로 (실측, python3 표준 라이브러리) protobuf 21바이트 JSON 한 줄 72바이트 JSON 들여쓰기 111바이트
Diagram: Breakdown of the 21-byte Rally message by field and size comparison with JSON

3. Four Rules

3.1 varint: 7 bits at a time, starting from the least significant bits

Integers are sliced into 7-bit chunks, written starting from the lowest bits , and if there are more bytes following, the most significant bit (MSB) is set to 1. 150 is binary 1001 0110 so the lower 7 bits 001 0110 with the continuation bit set becomes 0x96, and the remaining 1 becomes 0x01 yielding 96 01 . This is a manually calculated value.

Value Bytes Size
1 01 1
127 7f 1
128 80 01 2
150 96 01 2
300 ac 02 2
16,384 80 80 01 3
2,147,483,647 ff ff ff ff 07 5
int32 -1 ff ff ff ff ff ff ff ff ff 01 10

The last line is a trap. int32 's negative numbers are extended to 64-bit two's complement and take 10 bytes (see the "Signed Integers" section of the Encoding documentation). Fields where negative numbers appear frequently use sint32/sint64 . ZigZag maps 0, -1, 1, -2, 2... to 0, 1, 2, 3, 4... and then writes them as varints.

sint32 value ZigZag Bytes
-1 1 01
1 2 02
-2 3 03
-64 127 7f
64 128 80 01

3.2 Tag = (Field Number << 3) | Wire Type

The wire type only indicates the shapeof the value. 0 is VARINT (int32, int64, uint, bool, enum, sint), 1 is I64 (fixed64, double), 2 is LEN (string, bytes, message, packed bundle), 5 is I32 (fixed32, float), while 3 and 4 are legacy group types and are not used. Tags are also varints, so larger numbers make them longer.

Field Number Wire Type Tag Byte
1 0 08
2 2 12
15 0 78
16 0 80 01
2047 0 f8 7f
2048 0 80 80 01

Numbers 1-15 take 1 byte for the tag, and numbers 16-2047 take 2 bytes. This is why the guide recommends giving smaller numbers to frequently populated fields. Numbers can range from 1 to 536,870,911 (2²⁹-1), with 19,000 to 19,999 reserved for implementation use (proto3 guide).

3.3 LEN: Length Prepended

Strings, bytes, submessages, and packed bundles are 태그 + 길이(varint) + 내용 . In proto3, numeric repeated are packed by default , so hits=[3,270,1] is a single tag 1a(number 3, LEN) + length 04 + 03 8e 02 01 . If it were not packed, each value would have a tag attached, taking 7 bytes (calculated).

3.4 Default Values Are Not Sent

Standard scalar fields in proto3 (implicit presence) are not serializedif their value is 0, empty string, or false. A Rally where all fields are default values is 0 bytes. The receiving side cannot distinguish between "0 was sent" and "not sent." If distinction is necessary, optional is added to explicitly specify presence (Field Presence in proto3 guide). The health check request HealthCheckRequest{service:""} in Part 5 being actually 0 bytesis also due to this rule.

4. Try It Yourself: Hand Encoding vs. JSON

def varint(n):
    if n < 0:
        n &= (1 << 64) - 1          # int32 음수는 64비트로 늘려 10바이트
    out = bytearray()
    while True:
        b, n = n & 0x7F, n >> 7
        out.append(b | 0x80 if n else b)
        if not n:
            return bytes(out)

def tag(field, wire):
    return varint((field << 3) | wire)

Result (Measured):

Field Bytes Size
1 rally_id=150 08 96 01 3
2 server="티티" 12 06 ed 8b b0 ed 8b b0 8
3 hits=[3,270,1] 1a 04 03 8e 02 01 6
4 won=true 20 01 2
5 delta=-2 (sint32) 28 03 2
Total 21

Writing the same content in JSON (with field names following the proto3 JSON convention of lowerCamelCase) was 72 bytes, indent=2 for a single unspaced line and 111 byteswhen indented. In this example, protobuf is 29% the size of a single JSON line. The difference grows larger when messages are mostly numeric and shrinks when they are mostly long strings. Comparison after compression (gzip) was not performed (unmeasured).

delta If int32 was declared as

5. Backward Compatibility: How to Skip Unknown Fields

Suppose a new version adds field number 6 string note = 6; and includes "hello" . 32 05 68 65 6c 6c 6f is appended to the end of the bytes. An old-version parser that does not know about field 6 reads wire type 2 (LEN) from tag 32 and skipsby the length of 5. Indeed, the script's simulation of an "old parser" (skip_fields) (6, 2, 6) finished reading to the end while printing . This is possible because tags indicate the shape of the value.

Instead of discarding unknown fields, proto3 preserves them and writes them back out during re-serialization(Unknown Fields in proto3 guide). However, converting to JSON or copying fields individually may cause them to disappear.

Here is a summary of what to keep in mind (proto3 guide summary).

Modification Safe? Reason
Add field (new number) Safe Old versions skip it, new versions read it as default value
Change field name Binary safe No names in bytes. However, JSON and TextFormat use names
Delete field Safe if numbers and names are blocked with reserved reserved Reusing it causes old data to be read with new meanings
Reuse/change number Dangerous Misinterpretation can lead to data corruption or leaks
int32 ↔ int64 ↔ uint32 ↔ bool Wire compatible All VARINT. Truncated if out of range
int32 ↔ sint32 Incompatible ZigZag interpretation differs, changing values (-2 ↔ 3)

The last line can be verified with the example in Section 4. Reading 28 03 as int32 gives 3, and reading it as sint32 gives -2.

6. Limitations

7. Connection to Small Clusters

CRI messages between kubelet and containerd also use this format. Therefore, even if kubelet is newer than containerd (even if new fields are added to CRI), older containerd can work by skipping unknown fields. Conversely, this is also why the CRI side does not arbitrarily change field numbers. Raising the limit on kubelet CRI responses from the gRPC default of 4MiB to 16MiB (Part 6) is because lists of hundreds of containers are contained in a single message.

TT's Note. Simply writing 「Field 1: 150」 on the scoreboard is enough as long as both sides share the same format. However, writing negative scores in an int32 field takes 10 spaces, so use an sint32 field instead!

References

All materials were accessed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment