protobuf-versioning-strategy-reference
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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill protobuf-versioning-strategy-referenceprotobuf-versioning-strategy-reference
Overview
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/ (opens in new window): "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.
When to use
Field-number rules
| 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.
Binary wire-safe changes
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 |
Wire-compatible changes (conditionally safe)
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.
Wire-incompatible changes (always breaking)
Oneof constraints
Per protobuf.dev (opens in new window):
Map constraints
Per protobuf3 docs:
buf breaking-change taxonomy
Per buf.build/docs/breaking/rules (opens in new window), buf organises detection into four categories, strictest to most lenient. Full rule-ID tables: references/buf-breaking-rules.md.
Choosing the category
# 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 baselineCommon patterns
Removing a field
Always 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-patterns
| 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 |
Limitations
References
buf breaking-rule tables and worked evolution patterns
View source (opens in new window)buf breaking-rule tables and worked evolution patterns
Full rule-ID tables for buf's four breaking categories, plus worked proto-evolution diffs. Sources: buf.build/docs/breaking/rules (opens in new window) and protobuf.dev/programming-guides/proto3/ (opens in new window).
FILE (default)
| Rule | Detects |
|---|---|
ENUM_NO_DELETE | Removed enum |
MESSAGE_NO_DELETE | Removed message |
SERVICE_NO_DELETE | Removed service |
FILE_NO_DELETE | Removed file |
FIELD_SAME_NAME | Renamed field |
FIELD_SAME_TYPE | Type change |
FIELD_SAME_CARDINALITY | singular <-> repeated |
PACKAGE
| Rule | Detects |
|---|---|
PACKAGE_NO_DELETE | Removed package |
PACKAGE_ENUM_NO_DELETE | Enum deletion across files in package |
PACKAGE_MESSAGE_NO_DELETE | Message deletion across files |
WIRE_JSON
| Rule | Detects |
|---|---|
ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVED | Deleted enum value without reserve |
FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED | Deleted field without reserve |
FIELD_SAME_JSON_NAME | JSON field name change |
WIRE (most lenient)
| Rule | Detects |
|---|---|
FIELD_WIRE_COMPATIBLE_TYPE | Type change incompatible at wire level (allows int32->int64 etc.) |
FIELD_WIRE_COMPATIBLE_CARDINALITY | Cardinality change incompatible at wire |
Worked evolution patterns
Adding an optional field
Safe (always):
message User {
string name = 1;
+ string nickname = 2;
}Renaming a field
Add new, deprecate + reserve old:
message User {
string name = 1;
- string nickname = 2;
+ string display_name = 3;
+ reserved 2;
+ reserved "nickname";
}Consumers must migrate from nickname to display_name. The wire format reads either; the codegen forces consumers to update.
Promoting int32 to int64
Wire-compatible per protobuf3 docs:
message Counter {
- int32 count = 1;
+ int64 count = 1;
}Old clients writing int32 still parse correctly. Old clients reading new int64 data truncate silently if the value exceeds int32 range.
Adding a field to a oneof
ALWAYS BREAKING. Don't.
message Event {
oneof body {
string text = 1;
bytes binary = 2;
+ string emoji = 3; // BREAKS old parsers
}
}Mitigation: add the new variant as a non-oneof field; promote later in a separate proto file/package.
Related skills
buf-cli-lint-breaking-build
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; for the catalog of what is breaking and why use protobuf-versioning-strategy-reference, and for cross-service schema contract testing use protobuf-compat-checking - not this.
ghz-load
Wraps ghz, the gRPC load testing tool, for throughput and latency benchmarking. Covers test invocation (--proto + --call + host:port; or --protoset for compiled descriptors), load parameters (-n total requests, -c concurrency, -r RPS rate limit, -z duration), output formats (json/csv/html/influx-summary for CI consumption), the metrics reported (RPS achieved, latency p50/p95/p99, status-code distribution, errors), and CI integration patterns for regression gating. Use when benchmarking a gRPC service's throughput or detecting latency regressions in CI.
grpc-interceptor-test-author
Authors unit tests for gRPC interceptor logic: Go grpc.UnaryServerInterceptor/UnaryClientInterceptor, Java ServerInterceptor/ClientInterceptor, and grpc-js client interceptors. Covers auth (Unauthenticated on bad token), retry (backoff on Unavailable), logging/tracing (metadata extraction + propagation), error-mapping (status translation), and chained interceptor ordering - by calling the interceptor directly with a spy handler, no live backend. Use when a gRPC interceptor is written or modified. Different test surface from grpc-streaming-test-author (multi-message stream sequences) and grpc-mock (service handler logic) - use those, not this, for streams or handlers.
grpc-mock
Wraps gRPC server-mocking patterns for client-side tests: Go bufconn (in-memory net.Listener via google.golang.org/grpc/test/bufconn) + mockgen-generated interface mocks, Python pytest-grpc fixtures + unittest.mock patching of stubs, JVM grpc-mock library / in-process gRPC server (InProcessServerBuilder), Node @grpc/grpc-js fake server with NewServer-on-port-0. Use when writing client-side tests that need a controllable gRPC server response (success cases, error cases per grpc-status-code-mapping-reference, timeouts, and single-response error injection) without spinning up a real backend. For multi-message streaming-sequence tests (server-streaming, bidi), use grpc-streaming-test-author instead. Distinct from grpcurl-cli (ad-hoc CLI invocation against a real server) and ghz-load (perf against a real server).
grpc-status-code-mapping-reference
Pure-reference catalog of gRPC standard status codes - the 17 canonical codes (OK..UNAUTHENTICATED), their numeric values, semantics, retry behaviour per AIP-194 (only UNAVAILABLE is auto-retry-safe), and the gRPC-to-HTTP status mapping used by grpc-gateway (NOT_FOUND→404, INVALID_ARGUMENT→400, PERMISSION_DENIED→403, UNAUTHENTICATED→401, RESOURCE_EXHAUSTED→429, FAILED_PRECONDITION→400 not 412, ABORTED→409, UNAVAILABLE→503, DEADLINE_EXCEEDED→504, etc.). Use when designing a gRPC service's error vocabulary, writing assertions in gRPC client tests, configuring retry policies, or mapping gRPC errors to HTTP via a gateway. Consumed by buf-cli-lint-breaking-build, ghz-load, grpcurl-cli, grpc-mock, grpc-streaming-test-author.
grpc-streaming-test-author
Workflow-driven skill that builds gRPC streaming-RPC test suites from a proto definition. Classifies each RPC by pattern (unary, server-streaming, client-streaming, bidi), then emits the required categories per pattern - ordering preservation, completion semantics (server close after stream end, client half-close), cancellation, deadline handling, partial-stream failure. Produces skeletons for Go (bufconn + Send/Recv), Python (iterators), JVM (StreamObserver), Node (call.write/end). Use when adding tests for a new streaming RPC or auditing a suite for uncovered categories. Different test surface from grpc-interceptor-test-author (interceptor layer) and grpc-mock (harness); for wire-level streaming semantics use grpc-streaming-tests, not this.
grpcurl-cli
Wraps grpcurl, the curl-equivalent CLI for gRPC. Covers descriptor sources (server reflection default, --import-path + --proto for proto files, --protoset for compiled descriptor sets), service discovery (`list`, `describe`), invoking unary RPCs (`-d '{...}'`, `-d @file.json`, `-d @` for stdin), streaming RPCs (newline-delimited JSON via stdin), TLS configuration (--cacert, --cert, --key, --insecure, --plaintext), header injection (-H 'Authorization: Bearer ...'), and exit codes. Use for ad-hoc gRPC debugging, smoke testing, scriptable PR-time gates, and CLI-based interaction with reflective gRPC services.