Skip to content

Because the schema is written down, breaking changes can be caught before they ship. A CI check like buf breaking compares your schema against the previous version and flags the exact line.

Not every change breaks in the same way. Renaming a field, for example, is invisible on the binary wire but breaks JSON clients. Breaking changes fall into four categories:

  • WIRE: The most severe level. Changing a field number or switching to an incompatible type (such as string to int32) corrupts data when old and new endpoints decode each other's messages. Never do this.
  • WIRE_JSON: Breaks clients that use the JSON encoding. Renaming a field is safe on the binary wire, but clients expecting the old JSON key will fail. Setting [json_name="old_name"] keeps the old key. Even adding a field needs care here: binary readers skip fields they don't know, but standard ProtoJSON parsers reject unknown keys by default, so a JSON client on the old schema fails once the new field starts appearing, unless it was set to ignore unknown fields.
  • PACKAGE: Breaks generated code within a package. Changing a type in a wire-compatible way (such as int32 to int64) keeps old and new endpoints decoding each other's messages, as long as values stay in the old range until every reader is upgraded: an old reader truncates anything bigger. Builds still fail when developers regenerate until they update their types.
  • FILE: The strictest level. It protects generated code down to the individual file. Moving a message to another file can break code that imports it from the old location.
BEFORE
edition = "2024";
package api.v1;

message User {
  string id = 1;
  int32 age = 2;
  string display_name = 3;
}
AFTER
edition = "2024";
package api.v1;

message User {
  // [WIRE] breakage: type changed from string
  int32 id = 1; 

  // [PACKAGE] breakage: source code type change
  int64 age = 2;

  // [WIRE_JSON] breakage: JSON key changed
  string full_name = 3; 
}

Protobuf identifies data on the wire by field number, so removing a field takes more than deleting its line. Once a schema has been in production, older clients, queues, and databases still hold messages that use the number, and a new field that reused it would decode their data as its own. A field is retired in stages instead, shown in the panels below.

First, mark it [deprecated = true]. Nothing changes on the wire, but generated code picks up the annotation, @Deprecated in Java for instance, and editors start warning anyone who still writes to it. Next, use whatever metrics you have to confirm nothing reads or writes the field anymore. Then remove it, and in the same change add its number and its name to reserved statements, so the compiler rejects any later attempt to use either. Keep the reservation forever, since old data with that number can turn up at any time.

Step 1: The original schema
message Product {
  int32 price_cents = 1;
}
Step 2: Deprecate the old field, add the new one
message Product {
  int32 price_cents = 1 [deprecated = true];
  int64 price_micros = 2;
}
Step 3: Remove the old field and reserve its ID/name
message Product {
  reserved 1;
  reserved price_cents;

  int64 price_micros = 2;
}

Further Reading

Next

Field Presence

Explicit and implicit field presence in Protobuf: how to tell an unset field from a zero value, and how presence behaves across proto3 and editions.