Testland
Browse all skills & agents

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-reference
View source

protobuf-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

  • Designing a proto change - is this safe?
  • Auditing an existing schema for risky patterns (un-reserved deleted fields, oneof-conversion footguns).
  • Configuring buf breaking - which category fits the deployment model?
  • PR review of .proto changes.

Field-number rules

RangeUse
1..15Single-byte encoded; reserve for hot fields (frequently set)
16..2047Two-byte encoded; general use
2048..536,870,911Higher-byte encoded; rare-use fields
19,000..19,999Reserved 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:

ChangeWhy safe
Adding fieldsUnknown fields preserved (proto3 since 3.5)
Removing fields with reservationNumber recycling prevented
Adding enum valuesUnknown values pass through
Converting single explicit-presence field into a one-field oneofWire format identical

Wire-compatible changes (conditionally safe)

These preserve wire compatibility but may be lossy or surprising:

Type changeNotes
int32uint32int64uint64boolInteger types interchangeable; negative values may round-trip oddly for unsigned
sint32sint64Compatible only with each other, not with the unsigned family
fixed32sfixed32Same fixed-width family
fixed64sfixed64Same fixed-width family
stringbytesCompatible only if bytes are valid UTF-8
enumint32 / uint32 / int64 / uint64Enum 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)

  • Changing field numbers is equivalent to deleting and re-adding. Always breaks.
  • Moving fields into an existing oneof is not safe.
  • Changing map<k,v> key or value type.
  • Changing field cardinality from singular to repeated (or vice versa) outside the wire-compatible paths.

Oneof constraints

Per protobuf.dev (opens in new window):

  • Oneof fields cannot be repeated or map.
  • "If multiple values are set, the last set value as determined by the order in the proto will overwrite all previous ones."
  • Adding a field to an existing oneof is always breaking - old code can't represent the new variant; new data crashes old parsers.
  • Removing a field from a oneof is breaking for the same reason.
  • Converting a singular field into a single-field oneof: safe.
  • Converting a single-field oneof back to singular: safe.

Map constraints

Per protobuf3 docs:

  • Maps cannot be repeated.
  • Key types: integral and string scalars only. "neither enum nor proto messages are valid for key_type."
  • Changing a map's key or value type is breaking.

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.

  • FILE (default) - "changes that move generated code between files, breaking generated source code on a per-file basis" (e.g. 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 - breakage at package level; permits file relocations inside a package (e.g. PACKAGE_MESSAGE_NO_DELETE). Choose when consumers import by package (Go, Python).
  • WIRE_JSON - "changes that break wire (binary) or JSON encoding" (e.g. FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED, FIELD_SAME_JSON_NAME). Choose when consumers use both binary and JSON encoding (REST gateway + grpc).
  • WIRE (most lenient) - only changes "compromising binary wire format compatibility" (e.g. FIELD_WIRE_COMPATIBLE_TYPE, allowing int32->int64). Choose when only wire format matters (binary-only protocols between server fleets you control).

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 only

CI invocation:

buf breaking --against ".git#branch=main"
# Compares the working tree against the main branch as baseline

Common 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-patternWhy it failsFix
Delete field without reserveFuture developer reuses number; semantic corruptionAlways reserved <n>; and reserved "name";
Change field number to be more compactWire incompatibility - equivalent to delete+re-addNever; keep numbers stable
Add field to existing oneofOld parsers crash on unknown variantAdd outside the oneof; integrate later via wrapper
Rename without deprecatingCodegen consumers break at compile timeAdd new field, reserve old name
Use enum value 0 for UNKNOWN and re-purposeHidden meaning changeReserve enum value 0 explicitly for UNSPECIFIED
Treat string ↔ bytes as always safeUTF-8 invariant requiredVerify the data is UTF-8 valid first
Skip buf breaking on PRManual review misses subtle breakagebuf breaking --against main in CI
Use WIRE category for codegen consumersGenerated code breaks on field rename even if wire OKUse FILE / PACKAGE
One global proto fileAll consumers locked to single evolution pathPer-bounded-context proto files

Limitations

  • buf categories cover binary breakage. Semantic breakage (a field's meaning changes) is not detectable. Document semantic changes in commit messages.
  • JSON name changes (camelCase ↔ snake_case) detected only in WIRE_JSON.
  • No transitive analysis. A message field whose type is an imported message: buf doesn't follow the import.
  • Doesn't enforce naming conventions. buf lint is separate from buf breaking.

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)

RuleDetects
ENUM_NO_DELETERemoved enum
MESSAGE_NO_DELETERemoved message
SERVICE_NO_DELETERemoved service
FILE_NO_DELETERemoved file
FIELD_SAME_NAMERenamed field
FIELD_SAME_TYPEType change
FIELD_SAME_CARDINALITYsingular <-> repeated

PACKAGE

RuleDetects
PACKAGE_NO_DELETERemoved package
PACKAGE_ENUM_NO_DELETEEnum deletion across files in package
PACKAGE_MESSAGE_NO_DELETEMessage deletion across files

WIRE_JSON

RuleDetects
ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVEDDeleted enum value without reserve
FIELD_NO_DELETE_UNLESS_NUMBER_RESERVEDDeleted field without reserve
FIELD_SAME_JSON_NAMEJSON field name change

WIRE (most lenient)

RuleDetects
FIELD_WIRE_COMPATIBLE_TYPEType change incompatible at wire level (allows int32->int64 etc.)
FIELD_WIRE_COMPATIBLE_CARDINALITYCardinality 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.