Producers and consumers deploy on different clocks. The whole point of a schema-ful format is that a new writer and an old reader — in either order — keep working. Protobuf makes that possible if you follow a short list of rules.
Protobuf aims for both at once, which is what lets you roll out producers and consumers independently. It works because of one wire-format property: an unknown field is skippable (the tag carries the wire type, so a reader knows its length even without the schema — see The Wire Format).
| Change | Why it's safe |
|---|---|
| Add a new field (fresh number) | Old readers skip it; new readers see the default when it's absent |
| Rename a field | The wire uses the number; the name is local to generated code |
Remove a field — but reserved its number | Prevents the number from being accidentally reused later |
int32 ⇄ int64 ⇄ uint32 ⇄ bool ⇄ enum | All wire type 0 (varint); values round-trip within range |
sint32 ⇄ sint64 | Same zigzag+varint encoding |
string ⇄ bytes | Both wire type 2; safe when the bytes are valid UTF-8 |
Add a value to an enum | Unknown values are preserved as the raw number by modern runtimes |
| Change | What breaks |
|---|---|
| Change a field's number | Old data lands in the wrong field or is silently dropped |
| Reuse a retired number for new data | Old messages get misinterpreted as the new field — data corruption |
int32 → sint32 | Different encoding (plain varint vs zigzag); negatives decode wrong |
Varint type → fixed32/fixed64 | Different wire type; the parser mis-frames the value |
optional/singular → repeated of a fixed-width type | Layout mismatch; readers can misparse |
Move a field into/out of a oneof | Changes presence semantics; can drop data |
string email = 4;, and months later a
new dev adds int64 login_count = 4;. Old records still on a queue carry a
length-delimited string under field 4 — now decoded as an integer. Nothing errors; the data is just
wrong. Reserving the number would have made the reuse a compile error.
reserved — Tombstone the NumberWhen you retire a field, reserve its number (and optionally its name) so no one can revive it by accident:
message User {
reserved 4, 8, 15 to 20; // numbers that must never be reused
reserved "email", "phone"; // names too, to block accidental re-add
string name = 1;
int32 id = 2;
bool admin = 3;
// field 4 (email) is gone — and can never come back with a new meaning
}
reserved entry with its number. A schema linter in CI that rejects number reuse and
type-incompatible edits turns "remember the rules" into "the pipeline enforces them."
When an old reader parses a message containing fields it doesn't recognize, most runtimes retain the raw bytes rather than discarding them. If that reader re-serializes the message, the unknown fields survive the round trip — critical for proxies and middleware that pass messages through without fully understanding them.