This is one of the superpowers of schema-driven APIs: because the contract is written down, you can detect a change that would break clients before it ships. With a schemaless JSON API, you find out in production. With Protobuf, 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. This includes changing a field number or using an incompatible type (e.g.,
stringtoint32). This causes data corruption when old and new endpoints decode each other's messages; you should never do this. - WIRE_JSON: Breakage in JSON representation. Renaming a field is safe on the binary wire, but clients expecting the old JSON key will fail. You can mitigate this using the
[json_name="old_name"]annotation. - PACKAGE: Source code breakage at the package level. Changing a type in a wire-compatible way (e.g.,
int32toint64) transmits safely, but when developers update their generated code, their builds will fail until they update their types. - FILE: The strictest level. This ensures source code compatibility down to the individual file level. Moving a message to another file might break code generation that relies on specific file imports.
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 using field numbers rather than names, so deleting a field requires careful handling. If a schema has been used in production, older clients or databases may still hold data serialized with those field numbers. You cannot remove a field and reuse its number without risking collisions. Instead, you must manage its lifecycle:
- Deprecate: Add
[deprecated = true]. This warns developers in their IDEs (via generated code annotations like@Deprecated) not to use it for new features. - Stop Using: Wait until metrics show zero traffic using the field.
- Reserve: Remove the field entirely and add its number/name to a
reservedblock. This prevents future developers from accidentally reusing the number and corrupting old data that might still be in a database.
message Product { int32 price_cents = 1; }
message Product { int32 price_cents = 1 [deprecated = true]; int64 price_micros = 2; }
message Product { reserved 1, "price_cents"; int64 price_micros = 2; }
Further Reading
- Tip of the week #4: Accepting mistakes we can't fix
Some schema mistakes can't be rolled back once data exists; evolution is about living with 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.