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
stringtoint32) 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
int32toint64) 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.
edition = "2024"; package api.v1; message User { string id = 1; int32 age = 2; string display_name = 3; }
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.
message Product { int32 price_cents = 1; }
message Product { int32 price_cents = 1 [deprecated = true]; int64 price_micros = 2; }
message Product { reserved 1; reserved price_cents; int64 price_micros = 2; }
Further Reading
- Tip of the week #4: Accepting mistakes we can't fix
Schema mistakes that can't be undone once data exists, and how to work around them.
- Tip of the week #1: Field names are forever
The rename that's wire-safe but still breaks generated code and JSON.
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.