Wraps the buf CLI for protobuf PR gating: `buf build` (compile .proto), `buf lint` (STANDARD rules: snake_case fields, Service suffix), `buf breaking --against {ref}` (detect wire/codegen breakage vs a git/BSR baseline), and `buf format`. Use as the CI proto-lint + breaking-change gate, or to debug a breaking failure by rule ID (e.g. FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED) and pick the FILE/PACKAGE/WIRE_JSON/WIRE ruleset per consumer. This is the detection TOOL that enforces the rules and carries the catalog of what is breaking and why (field-number reservation, wire-safe vs wire-incompatible changes, oneof/map constraints, the four buf categories) in references/versioning-strategy.md; for cross-service schema contract testing use protobuf-compat-checking - not this.
72
90%
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 is the catalog of what is breaking and why; the host SKILL.md is
the detection workflow (buf breaking in CI).
.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: 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:
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 and
grpc-streaming-test-author.protobuf-compat-checking.