🧬 Schema Evolution

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.

Two Directions of Compatibility

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

flowchart LR NW["New producer
writes field 7"] -->|bytes| OR["Old consumer"] OR -->|"doesn't know 7
→ skips it"| OK["parses fine ✅
(forward compat)"] OW["Old producer
omits field 7"] -->|bytes| NR["New consumer"] NR -->|"field 7 absent
→ default value"| OK2["parses fine ✅
(backward compat)"]

The Golden Rule

Field numbers are forever. The number — not the name, not the position — identifies a field on the wire. Never change a live field's number, and never reuse a retired one for a different meaning. Names are free to change; numbers are load-bearing.

Safe Changes ✅

ChangeWhy it's safe
Add a new field (fresh number)Old readers skip it; new readers see the default when it's absent
Rename a fieldThe wire uses the number; the name is local to generated code
Remove a field — but reserved its numberPrevents the number from being accidentally reused later
int32int64uint32boolenumAll wire type 0 (varint); values round-trip within range
sint32sint64Same zigzag+varint encoding
stringbytesBoth wire type 2; safe when the bytes are valid UTF-8
Add a value to an enumUnknown values are preserved as the raw number by modern runtimes

Unsafe Changes ❌

ChangeWhat breaks
Change a field's numberOld data lands in the wrong field or is silently dropped
Reuse a retired number for new dataOld messages get misinterpreted as the new field — data corruption
int32sint32Different encoding (plain varint vs zigzag); negatives decode wrong
Varint type → fixed32/fixed64Different wire type; the parser mis-frames the value
optional/singular → repeated of a fixed-width typeLayout mismatch; readers can misparse
Move a field into/out of a oneofChanges presence semantics; can drop data
The classic outage: someone deletes 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 Number

When 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
}
Make it mechanical: deleting a field is a two-line change — remove the line, add a 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."

Unknown Fields Are Preserved (usually)

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.

Real-world: this is why you can put a schema-lagging gateway in front of evolving backend services. The gateway parses the fields it knows, forwards the rest untouched, and nobody loses data. Pair that with a shared schema registry and additive-only changes, and rolling deploys stop being scary. Next: is any of this worth leaving JSON?