Skip to content

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., string to int32). 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., int32 to int64) 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.
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 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:

  1. Deprecate: Add [deprecated = true]. This warns developers in their IDEs (via generated code annotations like @Deprecated) not to use it for new features.
  2. Stop Using: Wait until metrics show zero traffic using the field.
  3. Reserve: Remove the field entirely and add its number/name to a reserved block. This prevents future developers from accidentally reusing the number and corrupting old data that might still be in a database.
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, "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.