studyDDIAChapter 5 · Encoding and Evolution

Varint & Zigzag — −1 Costs 10 Bytes, or 1

Protobuf stores integers as varints, so small positives are one byte — but a plain int64 sign-extends every negative to the full width, so −1 costs 10 payload bytes. The sint64 type applies zigzag encoding and brings −1 back to 1 byte. Pick the wrong type for a column of small negatives and the batch comes out ~7× larger.


Concept

Protocol Buffers stores integers as varints — variable-length, so a small positive number is a single byte. But a plain int64 (or int32) encodes a negative value by two's-complement sign extension to the full 64-bit width, so the number −1 — all ones in binary — fills all ten varint payload bytes. The sint64/sint32 types apply zigzag encoding, which folds small-magnitude negatives back down to small varints, so −1 is one payload byte again. You will predict the per-value sizes, watch −1 cost 10 payload bytes as int64 versus 1 as sint64, then encode 1000 small negatives each way and see the plain int64 batch come out ~7× larger for a single wrong field type.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 5 — Encoding and Evolution, §"Protocol Buffers" (varint encoding; negative numbers and zigzag):

It uses variable-length integers: the number 1337 is encoded in two bytes

The book notes that with the plain varint a negative number always occupies the full width, and that the sint types exist precisely to fix that. Here you encode both and measure the gap — down to the byte.

Prerequisites

The surprise is about how a varint spends its bytes on a negative number. These help; each line notes where it shows up.

  1. Varint encoding — Protobuf packs an integer into 1–10 bytes, 7 value bits per byte with a continuation bit; small positives need few bytes. This is why 1 is one payload byte and 1337 is two (see the per-value table).
  2. Two's complement & sign extension — a negative int64 has its high bits all set, so −1 is 64 one-bits; the varint must emit every one of them, which is the 10-byte payload you will measure.
  3. Zigzag encodingsint's trick: map 0,−1,1,−2,2,… to 0,1,2,3,4,… so small magnitudes (positive or negative) stay small varints. This is why sint64 brings −1 back to one payload byte.
  4. Field tags & the wire format — each field is a (tag, value) pair; the tag is one byte here, so an isolated single-field message measures 1 tag + payload. That is why the −1 totals read 11 and 2, not 10 and 1.
Where to learn the prerequisites Varints, tags, wire types (#1, #4): the Protocol Buffers "Encoding" spec page — it walks the byte layout of a varint and a tag. Zigzag (#3): the same spec's "Signed integers" section, which gives the (n << 1) ^ (n >> 63) mapping. Two's complement (#2): any systems-programming reference; the one fact you need is that −1 is all ones. If only one is new, make it #3 — zigzag is the whole reason sint exists.
Environment these numbers came from Deterministic, so the byte counts reproduce exactly. Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, Python 3.12.13 via uv, with Protocol Buffers (grpcio-tools compiling the .proto at runtime + the protobuf runtime) — no Docker. Four single-purpose messages: Plain { int64 a }, Zig { sint64 a }, and their repeated counterparts, so each measurement isolates one field type.

Mental model

Four one-field messages, so len(SerializeToString()) is tag + payload and the payload is what varies:

Every field's tag is one byte (field 1, wire type 0 → 0x08), so subtract 1 from each total to read the varint payload. The whole exercise is one script, code/varint_zigzag.py.

Setup

cd study/ddia/ch05/code
uv run --python 3.12 --with grpcio-tools --with protobuf \
    python3 varint_zigzag.py

Do not read the output yet — make each prediction first.


Step 1 · predict the per-value sizes

Predict. For each value, guess the encoded size as int64 and as sint64, in bytes (total = 1 tag byte + varint payload): 1, 1337, −1, −1000, 2147483647, −2147483648. (Most people expect the two columns to match — they do for the positives. The negatives are the surprise.)

Step 2 · measure per value

Do. Run the script.

How does each value's int64 size compare to its sint64 size?
per-value encoded size (single-field message, so total = tag + payload): value int64 sint64 ratio 1 2b 2b 1.0x 1337 3b 3b 1.0x -1 11b 2b 5.5x -1000 11b 3b 3.7x 2147483647 6b 6b 1.0x -2147483648 11b 6b 1.8x the headline (-1): int64 = 1 tag + 10 varint payload = 11 bytes; sint64 = 1 tag + 1 varint payload = 2 bytes. the varint payload alone: 10 bytes vs 1 byte -- a 10x blowup for one sign bit.

Positives tie; negatives don't. For 1, 1337, and even the int32-max 2147483647, the two encodings are identical — zigzag buys nothing for positives. But every negative in the int64 column is 11 bytes (10 payload + 1 tag), regardless of magnitude: −1 and −2147483648 cost exactly the same, because both are sign-extended to the full 64 bits. sint64 tracks magnitude instead, so −1 is 2 bytes and −1000 is 3.

Step 3 · the batch that pays for it

Predict. Now encode 1000 small negative integers (each in −1..−100) as a packed repeated int64 and again as repeated sint64. Guess each total, and the ratio between them.

How much larger is the plain int64 batch?
batch of 1000 small negative ints (packed repeated field): repeated int64 10003 bytes repeated sint64 1363 bytes -> the plain int64 batch is 7.3x larger, wasting 8640 bytes on sign extension.

One wrong field type, ~7× the bytes. The int64 batch spends ~10 bytes per element (each small negative sign-extended to 10 payload bytes); the sint64 batch spends 1–2. Same numbers, same schema shape, same wire — the only difference is int64 vs sint64, and it costs 8640 wasted bytes on a thousand-element array.


What you should see

Why

A varint stores an integer 7 bits at a time, low bits first, using the 8th bit of each byte as a "more bytes follow" flag. A small positive number has only a few significant bits, so it fits in one or two bytes — that is the whole appeal of varints. The catch is negative numbers. Protobuf's plain int32/int64 represents them in two's complement, where a negative value has all its high bits set: −1 is 64 one-bits. The varint has no way to know those leading ones are "just the sign" — it must emit all 64 significant bits, which takes ⌈64 / 7⌉ = 10 payload bytes. That is why any negative int64, from −1 to the most negative int32, encodes to the same 10-byte payload: they all have the top bits set.

Zigzag fixes this by choosing a different mapping to the non-negative varint space. Instead of two's complement, sint64 encodes n as (n << 1) ^ (n >> 63) — interleaving the signs: 0 → 0, −1 → 1, 1 → 2, −2 → 3, and so on. Now the varint length tracks magnitude, not sign, so a small negative is as cheap as a small positive. Positives gain nothing (the map is a shift-and-XOR that keeps them small either way), which is exactly what the tied columns show. The batch just multiplies the per-element gap by a thousand: pick int64 for a column of small negatives and you ship ~7× the bytes for no benefit.

The boundary — the tax is on the sign, not the size, and only for the plain types sint is not a free upgrade: it loses to plain int when your values are overwhelmingly non-negative and occasionally large positives, because zigzag doubles a positive's value before encoding (which can push it into one more varint byte). The rule of thumb is: use sint32/sint64 when a field is frequently negative, plain int32/int64 when it is almost always non-negative, and fixed32/fixed64 when values are large and uniformly distributed. None of this touches float/double (always fixed-width) or the field tag (one byte here). The surprise is narrow but sharp: a single field declared int64 instead of sint64 can 10× a negative-heavy column, and Protobuf gives you no warning — it is a schema-design choice, made once, paid on every record.

Go deeper

Sources: DDIA 2e, Ch. 5, §"Protocol Buffers" · the Protocol Buffers "Encoding" spec (varints, wire types, signed integers / zigzag).