Skip to content

Evolving Schemas

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)"]
Schema changes must survive a mixed fleet

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.

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 fieldas long as you reserved its 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.

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. int32string). 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 repeated and singular, or moving a field in or out of a oneof.

The through-line: anything that changes what a number means is a breaking change, even though the .proto still compiles.

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.

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.

What makes protobuf schema evolution possible at all?
Which change is guaranteed safe?
Why is reusing a deleted field number so dangerous?
When you must make a truly incompatible change, what is the recommended approach?