Evolving Schemas
Old and new must coexist
Section titled “Old and new must coexist”In any real system, clients and servers deploy at different times. A new server will receive messages from old clients, and old servers will receive messages from new clients. Your schema must let both directions keep working — that is backward and forward compatibility, and protobuf is designed to make it achievable if you follow the rules.
flowchart TB newc["new client"] -->|extra fields| olds["old server (ignores unknown fields)"] oldc["old client"] -->|missing new fields| news["new server (reads defaults)"]
The mechanism that makes this work is the one you already know: fields are identified by number. An old server that receives a field number it doesn’t recognize simply skips it. A new server that doesn’t receive a field it expects reads the default. Numbers, not names, are what keep the peace.
Changes that are always safe
Section titled “Changes that are always safe”These never break a deployed client:
- Add a new field with a fresh, never-used number. Old code ignores it; new code reads it (default when absent).
- Remove a field — as long as you
reservedits number (below). Old clients that still send it are simply ignored. - Rename a field. The wire carries the number, so the name is free to change.
- Add a new value to an enum, or a new field to a
oneof. Old clients treat unknowns as unknown. - Add a new RPC method to a service. Existing clients don’t call it; nothing breaks.
Changes that break — silently
Section titled “Changes that break — silently”These compile fine and then corrupt data or crash clients in production:
- Reusing a deleted field’s number for a different field. Old bytes get decoded as the new type. This is the classic disaster.
- Changing a field’s type in an incompatible way (e.g.
int32→string). The wire bytes no longer match the reader’s expectation. - Renumbering an existing field. Every deployed client still uses the old number; you’ve just swapped two fields’ meanings.
- Changing a field between
repeatedand singular, or moving a field in or out of aoneof.
The through-line: anything that changes what a number means is a breaking change, even though the .proto still compiles.
The reserved discipline
Section titled “The reserved discipline”Because the danger is reuse, the defense is simple and mechanical: when you delete a field, immediately reserved its number and name.
message User { reserved 3, 7; // retired field numbers — never reuse reserved "email", "phone"; // retired names — catch accidental re-adds
int64 id = 1; string name = 2; bool verified = 4;}Now the compiler is your safety net: any future attempt to reuse number 3 or the name email fails to build. Tools like buf go further and can detect breaking changes automatically in CI by comparing your .proto against the previously published version — turning “did I just break every client?” into a failing pull request instead of a 2 a.m. incident.
Version in the package, not by breaking
Section titled “Version in the package, not by breaking”When you truly must make an incompatible change, don’t mutate the existing message — publish a new package version (user.v2) alongside user.v1 and migrate clients over time. That’s why you see package user.v1; everywhere: the version is baked into the type path so v1 and v2 can coexist.