Pure-reference catalog of protobuf3 versioning and breaking-change rules: field-number reservation (reserve on delete; 1..536870911; 19000-19999 reserved), wire-safe vs wire-incompatible changes (add/remove safe with reservation; changing a field number always breaks), compatible type conversions (int32/uint32/int64/uint64/bool; sint32/sint64; string/bytes for UTF-8; enum/int), oneof + map constraints, and buf's four breaking categories (FILE/PACKAGE/WIRE_JSON/WIRE) with rule IDs. Use when designing a schema change or picking a buf breaking ruleset. This is the catalog of what is breaking and why, not a scanner; to detect changes in CI use buf-cli-lint-breaking-build, for the gRPC status-code vocabulary use grpc-status-code-mapping-reference, and for cross-service contract testing use protobuf-compat-checking.
76
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Protobuf3 schema evolution is a wire-format problem first and a codegen problem second. The field number is the only durable identifier - every breaking-change rule derives from preserving field-number → type binding.
Per protobuf.dev/programming-guides/proto3/: "This number cannot be changed once your message type is in use because it identifies the field in the message wire format."
This skill is a pure reference consumed by buf-CLI lint, the
breaking-build CI integration, and the gRPC service authors. For
the detection workflow see
buf-cli-lint-breaking-build.
.proto changes.| Range | Use |
|---|---|
| 1..15 | Single-byte encoded; reserve for hot fields (frequently set) |
| 16..2047 | Two-byte encoded; general use |
| 2048..536,870,911 | Higher-byte encoded; rare-use fields |
| 19,000..19,999 | Reserved for Protocol Buffers implementation; never use |
When deleting a field, reserve its number:
message User {
reserved 4, 7, 10 to 12;
reserved "deprecated_email";
string name = 1;
// ...
}Per the spec: "If you do not reserve the field number, it is possible for a developer to reuse that number in the future." Reuse → semantic corruption: old clients interpret bytes as the old type.
Fully safe - old code parses new messages and vice versa with no loss:
| Change | Why safe |
|---|---|
| Adding fields | Unknown fields preserved (proto3 since 3.5) |
| Removing fields with reservation | Number recycling prevented |
| Adding enum values | Unknown values pass through |
| Converting single explicit-presence field into a one-field oneof | Wire format identical |
These preserve wire compatibility but may be lossy or surprising:
| Type change | Notes |
|---|---|
int32 ↔ uint32 ↔ int64 ↔ uint64 ↔ bool | Integer types interchangeable; negative values may round-trip oddly for unsigned |
sint32 ↔ sint64 | Compatible only with each other, not with the unsigned family |
fixed32 ↔ sfixed32 | Same fixed-width family |
fixed64 ↔ sfixed64 | Same fixed-width family |
string ↔ bytes | Compatible only if bytes are valid UTF-8 |
enum ↔ int32 / uint32 / int64 / uint64 | Enum is wire-encoded as varint |
The catch: a parser reading int64 data with int32 will
silently truncate. The wire is "compatible" but the data may be
lost.
map<k,v> key or value type.singular to repeated (or
vice versa) outside the wire-compatible paths.Per protobuf.dev:
repeated or map.Per protobuf3 docs:
repeated.key_type."Per buf.build/docs/breaking/rules, buf organises detection into four categories, strictest to most lenient. Full rule-ID tables: references/buf-breaking-rules.md.
MESSAGE_NO_DELETE, FIELD_SAME_TYPE, FIELD_SAME_CARDINALITY).
Choose when codegen consumers import per-file (most JVM, .NET,
generated stubs in a monorepo).PACKAGE_MESSAGE_NO_DELETE). Choose when
consumers import by package (Go, Python).FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED,
FIELD_SAME_JSON_NAME). Choose when consumers use both binary and
JSON encoding (REST gateway + grpc).FIELD_WIRE_COMPATIBLE_TYPE, allowing
int32->int64). Choose when only wire format matters (binary-only
protocols between server fleets you control).# buf.yaml
version: v2
breaking:
use:
- FILE # most strict (default)
# OR
- PACKAGE # codegen friendly within-package
# OR
- WIRE_JSON # wire + JSON
# OR
- WIRE # wire onlyCI invocation:
buf breaking --against ".git#branch=main"
# Compares the working tree against the main branch as baselineAlways reserve the number and the name:
message User {
+ reserved 2;
+ reserved "nickname";
string name = 1;
- string nickname = 2;
}More worked diffs - adding an optional field, renaming, promoting
int32 to int64, and the oneof footgun:
references/buf-breaking-rules.md.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Delete field without reserve | Future developer reuses number; semantic corruption | Always reserved <n>; and reserved "name"; |
| Change field number to be more compact | Wire incompatibility - equivalent to delete+re-add | Never; keep numbers stable |
| Add field to existing oneof | Old parsers crash on unknown variant | Add outside the oneof; integrate later via wrapper |
| Rename without deprecating | Codegen consumers break at compile time | Add new field, reserve old name |
Use enum value 0 for UNKNOWN and re-purpose | Hidden meaning change | Reserve enum value 0 explicitly for UNSPECIFIED |
| Treat string ↔ bytes as always safe | UTF-8 invariant required | Verify the data is UTF-8 valid first |
| Skip buf breaking on PR | Manual review misses subtle breakage | buf breaking --against main in CI |
| Use WIRE category for codegen consumers | Generated code breaks on field rename even if wire OK | Use FILE / PACKAGE |
| One global proto file | All consumers locked to single evolution path | Per-bounded-context proto files |
buf lint is separate
from buf breaking.buf-cli-lint-breaking-build,
grpc-streaming-test-author.buf-cli-lint-breaking-build.
For protobuf-schema contract-testing across services see
protobuf-compat-checking.