Protocol Buffers Schema Evolution and Versioning

Protocol Buffers are designed for forward and backward compatibility from the ground up. A .proto file is not a static contract frozen in time -- it is a living schema that must evolve alongside the systems that produce and consume its messages. Getting schema evolution wrong can corrupt data, crash services, or silently drop fields that downstream consumers depend on. This guide covers the full landscape of protobuf schema evolution: the wire compatibility rules that make safe changes possible, the tooling that enforces them, the well-known types that solve common problems, and the strategies teams use to version APIs built on Protocol Buffers and gRPC.

Wire Compatibility: The Fundamental Rules

Protobuf's binary encoding is built around field numbers and wire types, not field names. When a decoder encounters a field number it does not recognize, it skips over it based on the wire type. When it expects a field number that is absent from the bytes, it uses the default value. This design is the foundation of all safe schema changes.

Wire Compatibility Matrix Change Old Reader New Reader Safe? Add field Skips unknown field Uses default if absent YES Remove field Uses default value Skips old data WARN Rename field Unaffected (binary) Unaffected (binary) YES Change type Misinterprets bytes Misinterprets bytes NO Change number Sees field as missing Sees field as missing NO Reorder fields Unaffected Unaffected YES Field names are irrelevant on the wire. Field numbers are the identity.

Adding Fields

Adding a new field with a previously unused field number is always safe. Old code that reads a message containing the new field simply skips the unknown bytes. New code that reads an old message without the field sees the type's default value: zero for numbers, empty for strings and bytes, null/empty for messages. In proto3, there is no way to distinguish "field was set to the default value" from "field was absent," so new optional fields should use wrapper types or optional (reintroduced in proto3 revision) when presence matters.

Removing Fields

You can stop writing a field at any time -- old readers that expect it will see the default value, and new readers that no longer know about it will skip it in old data. The critical danger is reusing the field number. If you remove field 7 (string old_name) and later add a new field 7 (int32 count), old data containing a string in field 7 will be misinterpreted as an integer. This is silent data corruption. The solution is to mark removed fields as reserved.

Renaming Fields

Field names only matter in source code and JSON serialization. On the binary wire, only the field number matters. Renaming a field from user_name to username has zero effect on binary compatibility. However, if your system uses protobuf's JSON format (where field names become JSON keys), renaming is a breaking change for JSON consumers. Use the json_name option to decouple wire names from code names.

Changing Types

Changing a field's type is almost always a breaking change. There are a few narrow exceptions where the wire encoding happens to be compatible: int32, uint32, int64, uint64, and bool are all wire-compatible (varint encoding), though values may be truncated. Similarly, fixed32 and sfixed32 share the same 4-byte encoding, as do fixed64 and sfixed64. string and bytes are wire-compatible if the bytes happen to be valid UTF-8. But in practice, changing field types should be avoided -- add a new field with a new number instead.

Reserved Fields and Field Number Reuse

The reserved keyword exists to prevent the most dangerous schema evolution mistake: reusing a field number or name from a deleted field. When you remove a field, you must reserve either its number, its name, or both:

message User {
  reserved 3, 7, 10 to 15;
  reserved "old_email", "legacy_id";

  string name = 1;
  string email = 2;
  // field 3 was string old_email (removed 2024-01)
  // fields 10-15 were legacy login fields
}

If anyone later tries to define a field using a reserved number or name, the protobuf compiler (protoc) will reject it with an error. This is a compile-time safety net against silent data corruption. Note that you cannot mix field names and numbers in the same reserved statement -- they require separate declarations.

Field Number Reuse: Silent Corruption v1 field 3 = string "[email protected]" v2 (BAD) field 3 = int32 reads junk data! v2 (SAFE) reserved 3; field 4 = int32 Old data with string in field 3 will be decoded as int32 Result: garbage values, crashes, or security vulnerabilities

Field numbers 1 through 15 use a single byte for the tag on the wire, making them slightly more efficient. Numbers 16 through 2047 use two bytes. Numbers 19000 through 19999 are reserved by the protobuf specification itself. Once you assign a field number, treat it as permanent -- even after the field is removed, the number is spent forever for that message type.

Oneof Evolution Pitfalls

The oneof construct lets you express that exactly one of several fields is set. It is powerful but introduces evolution constraints that differ from regular fields:

message Event {
  oneof payload {
    ClickEvent click = 1;
    ViewEvent view = 2;
    // Adding PurchaseEvent later is safe
    PurchaseEvent purchase = 3;
  }
}

Adding a field to an existing oneof is safe. Old readers that encounter the new field number treat it as unknown and clear the oneof (in most language runtimes). New readers handle it normally.

Moving a field into a oneof is not safe. If field 5 was a standalone field and you move it into a oneof, old data that has both field 5 and another oneof member set will be corrupted -- the oneof semantics mean only one survives, but old writers did not know about this constraint.

Moving a field out of a oneof is equally dangerous. Code that relied on the mutual exclusion guarantee will encounter messages where multiple formerly-oneof fields are simultaneously set.

Splitting or merging oneofs is a breaking change. The wire format uses the field number to determine which oneof member is set, but the runtime behavior (clearing other members when one is set) depends on the schema definition at the reader. If the reader's schema groups fields differently than the writer's, the clearing behavior will be wrong.

Enum Evolution and Unknown Values

Proto2 and proto3 handle unknown enum values differently, and this difference is a common source of bugs during schema evolution.

In proto3, the first enum value must be 0, and it serves as the default. When a proto3 parser encounters an enum value it does not recognize (because the writer has a newer schema with more values), it preserves the unknown numeric value in the field. The getter returns the raw integer, and language-specific behavior varies -- Go gives you the numeric value directly, Java and C++ return the UNRECOGNIZED sentinel. If you serialize the message back out, the unknown value is preserved.

In proto2, unknown enum values are treated as unknown fields entirely -- the whole field is moved to the unknown fields set. This means a round-trip through a proto2 reader can lose the enum value from its expected position.

enum Status {
  STATUS_UNSPECIFIED = 0;  // required in proto3
  STATUS_ACTIVE = 1;
  STATUS_INACTIVE = 2;
  // Adding STATUS_SUSPENDED = 3 is safe
  // Never reuse numeric values!
  // Never remove values without reserving them
}

Best practices for enum evolution: always have an UNSPECIFIED or UNKNOWN zero value, never reuse numeric values, and use reserved for removed values just as you would for message fields. Consider how your application logic handles values outside the known set -- a switch statement without a default case will miss new enum values silently.

Enum Evolution: Proto2 vs Proto3 Proto3 Writer (new) sends value=3 Reader (old) keeps value=3 Unknown value preserved on round-trip Re-serialize: field still contains 3 Proto2 Writer (new) sends value=3 Reader (old) value dropped! Unknown value moved to unknown fields Re-serialize: field reverts to default

Breaking Change Detection with Buf CLI

Manually tracking which schema changes are safe is error-prone, especially in large organizations with hundreds of proto files. Buf is a tool built specifically to lint, format, and detect breaking changes in protobuf schemas. It replaces the need to remember all the rules described above with automated enforcement.

Buf's breaking change detection compares your current schema against a previous version and reports any changes that would break wire compatibility, JSON compatibility, or generated code:

# Check for breaking changes against the main branch
buf breaking --against '.git#branch=main'

# Check against a published BSR module
buf breaking --against 'buf.build/acme/petapis'

# Check against a saved image file
buf breaking --against 'image.binpb'

Buf categorizes breaking changes by severity:

You configure Buf with a buf.yaml at the root of your proto sources:

version: v2
breaking:
  use:
    - WIRE_JSON
lint:
  use:
    - STANDARD
  enum_zero_value_suffix: _UNSPECIFIED

Integrating buf breaking into CI ensures that no pull request can merge a breaking schema change without explicit review. This is far more reliable than code review alone, because humans routinely miss the subtle interactions between field numbers, wire types, and oneof groupings.

Proto File Organization

How you organize .proto files matters for schema evolution because import paths become part of your public API. Once another team imports acme/user/v1/user.proto, that path must remain stable.

Package Naming

Use a fully qualified package name that includes your organization, domain area, and version: package acme.user.v1;. This prevents collisions when multiple teams generate code into the same output directory, and it makes the version boundary explicit. The package name becomes part of the fully qualified type name in the protobuf type registry.

Import Paths

Structure your directory tree to mirror your package hierarchy: acme/user/v1/user.proto for package acme.user.v1. Never use relative imports. Every proto file should be importable via a stable, absolute path from a well-known root. Buf enforces this convention by default through its DIRECTORY_SAME_PACKAGE lint rule.

One Message per File vs. Grouped Files

There is no single right answer. Google's internal style uses one top-level message or service per file. Many teams group related messages in a single file (e.g., user.proto containing User, CreateUserRequest, CreateUserResponse). The important thing is consistency and that file paths remain stable. Moving a message to a different file does not affect wire compatibility but does break import paths for consumers.

Recommended Proto File Layout proto/ acme/ user/ v1/ user.proto package acme.user.v1; user_service.proto package acme.user.v1; v2/ user.proto package acme.user.v2; Directory = Package path Version in package name Stable import paths

API Versioning Strategies

When your schema changes grow beyond what field additions and deprecations can handle, you need a versioning strategy. There are two dominant approaches in the protobuf ecosystem.

Package Versioning

This is Google's recommended approach and what most gRPC API designers use. You create a new package version -- acme.user.v2 -- with a clean slate. The old version continues to exist and be served alongside the new one. Consumers migrate at their own pace.

The tradeoff is that you now maintain two (or more) versions of every message and service. Server implementations must handle both. The benefit is that each version is independently evolvable and you never have to make a breaking change within a version. Google Cloud APIs, for instance, use this pattern extensively: google.cloud.bigquery.v2, google.cloud.storage.v2.

Field Masks and Partial Updates

Instead of creating new versions, you use google.protobuf.FieldMask to let clients specify which fields they care about. A read request includes a field mask specifying which fields to return; an update request includes a field mask specifying which fields to write. Fields outside the mask are ignored.

message UpdateUserRequest {
  User user = 1;
  google.protobuf.FieldMask update_mask = 2;
  // Client sends: update_mask = "display_name,email"
  // Server only modifies those two fields
}

This reduces the need for new versions because adding a field to a message does not require any client changes -- clients that do not include the new field in their mask are unaffected. It also prevents accidental overwrites in update operations where a client reads a resource, modifies one field, and writes the whole thing back (potentially overwriting fields set by another client in the meantime).

Google Well-Known Types

The protobuf runtime ships with a set of "well-known types" in the google.protobuf package. These solve common representation problems and have special JSON serialization behavior. Using them instead of inventing your own representations ensures interoperability and reduces schema evolution friction.

google.protobuf Well-Known Types Timestamp seconds (int64) + nanos (int32) JSON: "2024-01-15T09:30:00Z" Duration seconds (int64) + nanos (int32) JSON: "3.5s" or "120s" FieldMask paths (repeated string) JSON: "name,email,address.city" Any type_url (string) + value (bytes) Embed any message type dynamically Struct fields (map<string, Value>) JSON: arbitrary JSON objects Wrappers Int32Value, StringValue, etc. Nullable scalars (presence detection) Empty No fields. For RPCs that return nothing. Status (google.rpc) code + message + details (repeated Any)

Timestamp and Duration

google.protobuf.Timestamp represents an absolute point in time as seconds and nanoseconds since the Unix epoch (1970-01-01T00:00:00Z). It serializes to RFC 3339 strings in JSON. Never use raw int64 for timestamps in new schemas -- you lose the self-documenting nature, the JSON format, and the validation that well-known types provide. Duration is the same structure but represents a time span rather than an absolute point.

Any

google.protobuf.Any wraps an arbitrary protobuf message in a type-safe envelope. It stores a type_url (like type.googleapis.com/acme.user.v1.User) and the serialized bytes. This is useful for extensible systems where the set of possible message types is not known at schema design time -- error details in gRPC status responses use repeated Any for exactly this reason. The receiver must have the target message's schema registered to unpack it.

FieldMask

As discussed in the versioning section, FieldMask contains a list of field paths as strings: ["name", "address.city", "metadata"]. It is used to specify which fields to read, update, or include in a response. For nested messages, paths use dot notation. FieldMask is critical for building APIs that can evolve without breaking clients -- new fields added to a message do not affect clients that specify their field mask.

Struct and Value

google.protobuf.Struct represents an arbitrary JSON-like object. It is a map<string, Value> where Value can be null, a number, a string, a bool, a list, or a nested struct. Use it when you genuinely need to store untyped, schema-less data (configuration blobs, metadata dictionaries). Avoid it for typed data -- you lose all the benefits of protobuf's schema validation and compact encoding.

Wrapper Types

In proto3, scalar fields cannot distinguish between "set to zero" and "not set" because both use the default value. Wrapper types like google.protobuf.Int32Value, StringValue, BoolValue solve this by wrapping the scalar in a message -- if the message field is absent, the value is null; if present, the inner scalar carries the actual value. Since proto3 added the optional keyword, wrappers are less necessary for new schemas, but they remain common in existing APIs (especially Google Cloud APIs).

Custom Options and Annotations

Protobuf allows extending the built-in descriptor types with custom options. These are metadata annotations attached to messages, fields, services, methods, enums, or files. They do not affect the wire format of the messages themselves, but they can carry information used by code generators, documentation tools, or runtime frameworks.

import "google/protobuf/descriptor.proto";

extend google.protobuf.FieldOptions {
  optional bool sensitive = 50000;
  optional string deprecated_reason = 50001;
}

message User {
  string name = 1;
  string ssn = 2 [(sensitive) = true];
  string old_email = 3 [deprecated = true,
    (deprecated_reason) = "Use email field instead"];
  string email = 4;
}

Custom options are defined by extending one of the *Options messages in google/protobuf/descriptor.proto. The field numbers for custom options must be unique to avoid collisions -- use numbers in the 50000-99999 range, or register a range with the protobuf global extension registry for larger organizations. Custom options are available at runtime through the descriptor/reflection APIs in every language runtime.

Common uses for custom options include: marking fields as containing PII or sensitive data (for automatic redaction in logs), specifying validation rules (field must be non-empty, value must be in a range), adding API documentation that code generators can extract, and marking fields with deprecation reasons that are richer than the built-in deprecated boolean.

Buf's buf validate plugin (formerly known as protoc-gen-validate or PGV) uses custom options extensively to define validation rules directly in the schema:

import "buf/validate/validate.proto";

message CreateUserRequest {
  string email = 1 [(buf.validate.field).string.email = true];
  string name = 2 [(buf.validate.field).string = {
    min_len: 1,
    max_len: 256
  }];
  int32 age = 3 [(buf.validate.field).int32 = {
    gte: 0,
    lte: 150
  }];
}

Migration from Proto2 to Proto3

Proto3, released in 2016, simplified the language by removing several proto2 features: required fields, default value declarations, groups, and extensions (replaced by Any). It also changed the default field presence semantics -- in proto2, every field tracks whether it was explicitly set; in proto3 (originally), scalar fields do not track presence and return the type's zero value when unset.

Migrating from proto2 to proto3 is not always necessary or advisable. The wire formats are compatible -- a proto2 reader can decode data written by proto3 code and vice versa (with caveats around unknown enum values, as discussed above). The migration is primarily a source-level concern.

Key considerations for migration:

Proto2 to Proto3 Migration Checklist 1. Remove all 'required' keywords -- enforce in validation layer 2. Remove custom default values -- move to application code 3. Add 'optional' to scalar fields that need has_* presence 4. Add UNSPECIFIED = 0 to every enum as the first value 5. Replace extensions with google.protobuf.Any or oneof 6. Change syntax = "proto2" to syntax = "proto3" (or editions)

A practical approach is incremental migration: change one file at a time, ensure the wire format remains compatible, and run buf breaking to verify no wire-level breaks occurred. Proto2 and proto3 files can coexist in the same project and import each other freely.

It is also worth noting that protobuf Editions (introduced in 2024) are the planned successor to both proto2 and proto3. Editions use a feature-based system where individual behaviors (field presence, enum handling, UTF-8 validation) are controlled by per-file or per-field feature settings rather than a global syntax version. This provides a smooth migration path from either proto2 or proto3 toward a unified system.

Buf Schema Registry (BSR)

The Buf Schema Registry (BSR) is a centralized registry for protobuf schemas, analogous to what npm is for JavaScript packages or crates.io is for Rust. It solves a set of problems that become acute in large organizations: How do consumers discover available schemas? How do you distribute generated code? How do you enforce governance policies across teams?

BSR modules are identified by a three-part name: buf.build/owner/repository. When you push a module to the BSR, it stores the proto source files, validates them against lint and breaking change rules, and generates documentation automatically. Consumers can depend on BSR modules from their buf.yaml:

version: v2
deps:
  - buf.build/googleapis/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
  - buf.build/bufbuild/protovalidate

Key BSR features for schema evolution:

Buf Schema Registry Workflow Developer edits .proto buf push BSR lint + breaking check + store Go SDK TypeScript SDK Python SDK Consumers go get npm install pip install CI/CD Integration PR opened buf lint buf breaking merge Breaking changes block merge. Lint violations block merge.

For teams not ready for a hosted registry, Buf also supports local references and Git-based references for breaking change detection, so you can integrate the tooling into your workflow without any external dependencies.

Putting It All Together: An Evolution Playbook

Schema evolution is not a one-time event. It is an ongoing discipline that every team maintaining a protobuf-based API must practice. Here is a practical playbook:

Protocol Buffers achieve their compatibility guarantees through a simple but strict set of rules about field numbers and wire types. The tooling ecosystem -- Buf CLI, BSR, protoc plugins -- exists to enforce these rules automatically so you do not have to rely on human memory. By understanding the wire format (covered in depth in How Protocol Buffers Work) and the evolution rules in this guide, you can confidently evolve schemas across years of production use, across dozens of teams, without breaking a single consumer.

The same principles apply whether you are building gRPC services, using protobuf for data storage, or serializing events into a message queue. The wire format does not care about the transport -- it cares about field numbers, wire types, and the discipline of never reusing what has been spent.

See BGP routing data in real time

Open Looking Glass
More Articles
How gRPC Works
How Protocol Buffers Work
How gRPC-Web Works
gRPC Load Balancing: Strategies and Patterns
gRPC and Service Mesh: Istio, Envoy, and Linkerd
gRPC Security: Authentication, TLS, and Authorization