How to Evolve a Protobuf Schema Without Breaking Old Clients and Block Unsafe Edits in CI With Python
Learn which Protocol Buffers schema changes are safe, measure what each unsafe one does to old and new readers in Python, and build a small gate that blocks them in CI.
What you will build, and why a schema change is risky
Protocol Buffers, usually called protobuf, turns structured data into a compact block of bytes and back again. You describe the data once in a .proto file, a compiler generates code for your language, and any two programs that share the file can exchange messages. It is used with gRPC and in many event streams, caches and files.
Table Of Content
- What you will build, and why a schema change is risky
- Terms to know before we start
- Prerequisites
- Create the project
- Step 1: Look at what a message really is on the wire
- 1.1 The baseline schema
- 1.2 Compile every version and load several at once
- 1.3 Read the bytes without a schema
- 1.4 Check that generated code agrees
- Step 2: Make the safe change, adding fields
- What just happened
- Step 3: Decide presence when you add a number
- Step 4: Remove a field without causing a disaster
- The fix: reserve numbers and names
- Step 5: Know which type changes survive
- Reading the table
- Step 6: Enums and oneofs
- New enum values and new oneof members
- Moving a field into an existing oneof
- Step 7: A rename is free in binary and expensive in JSON
- What the output shows
- Step 8: Build a gate that refuses unsafe edits
- 8.1 The rules
- 8.2 The code
- 8.3 Run it on the schemas from the earlier steps
- 8.4 Test the gate itself
- Step 9: Replay old bytes through the new schema
- Step 10: Run the gate in CI against the main branch
- Step 11: Cross-check with buf breaking
- Check that the whole thing works
- The cheat sheet
- What no tool can see
- Next steps
- Sources and method
The trouble starts the day you change that file. The program that writes a message and the program that reads it are almost never deployed at the same moment, and bytes written last month may still sit in a log or a queue. The protobuf best-practices guide says it plainly: “Clients and servers are never updated at exactly the same time”, and “One or the other may get rolled back.” Every schema edit therefore has to survive four meetings: an old writer with a new reader, a new writer with an old reader, and the same two pairings through any service in the middle.
In this tutorial you will edit a small shipping schema in several ways, some safe and some not, and watch what real Python code does with the bytes in each case. You will see a reused field number turn 2,500 grams into a parcel count of 2,500 without a single error. Then you will turn what you learned into three guards: a schema gate of about 230 lines that compares an old schema with a new one and fails on unsafe edits, a replay test that feeds old bytes through the new schema, and a CI command that runs the gate against your main branch. Finally you will compare the gate with buf breaking, an established tool, on the same changes.
Terms to know before we start
Schema and .proto file. The definition of your messages. It is the contract between programs.
Message and field. A message is a record, such as one shipment. A field is one named value inside it, such as weight_grams.
Field number. Each field has a number, written after the equals sign (weight_grams = 2). The number, not the name, is what travels in the bytes. This one fact explains most of this tutorial.
Wire format. The byte layout protobuf uses on the network or on disk. “Binary” and “wire” mean the same thing here.
Writer and reader. A writer serializes a message into bytes and a reader parses bytes back. “Old” and “new” describe which schema version a program was built with, so an old reader is a program that still has last quarter’s .proto.
Unknown field. A field in the bytes whose number the reader’s schema does not contain. The protobuf language guide defines unknown fields as “well-formed Protobuf serialized data representing fields that the parser does not recognize.”
Descriptor set. A file that protoc, the protobuf compiler, can write to describe a schema itself rather than data. Programs can load it to build message classes, and the gate you will write reads two of them and compares.
Wire-safe, wire-compatible, wire-unsafe. The language guide sorts changes into three groups. Wire-safe changes can be made “without risk of data loss or new parse failures”. Wire-compatible changes can be parsed before and after, but “a parse of the data may be lossy”. Wire-unsafe changes break parsing in at least one direction.
Prerequisites
- Python 3.10 or newer. Everything here ran on Python 3.13.14 on Windows 11, with protobuf’s default
upbimplementation. - Comfort reading Python. You do not need to know protobuf; every term is explained when it first appears.
- Git, only for Step 10. Any current version works.
- No separate
protocinstall. Thegrpcio-toolspackage ships a compiler, and you run it aspython -m grpc_tools.protoc. - Optional for Step 11: the single-file
bufbinary from its GitHub releases page (about 56 MB). The run in this tutorial used buf 1.73.0.
Create the project
Make a folder, create a virtual environment, activate it, and install three packages. The versions below are the ones this tutorial was run with.
mkdir protobuf-evolution
cd protobuf-evolution
python -m venv .venv
.venv\Scripts\activate
python -m pip install protobuf==7.36.2 grpcio-tools==1.84.0 pytest==9.1.1
python -m grpc_tools.protoc --version
On macOS and Linux the activation line is source .venv/bin/activate. The last command should print libprotoc 35.1. Every file below goes in the protobuf-evolution folder and every command is run from there.
Step 1: Look at what a message really is on the wire
Before changing a schema, it helps to see what a schema produces. You will define the first version of a Shipment message, serialize one, and read the raw bytes.
1.1 The baseline schema
Create the folder protos/v1 and save this as protos/v1/shipment.proto.
syntax = "proto3";
package shipping;
enum ShipmentStatus {
SHIPMENT_STATUS_UNSPECIFIED = 0;
SHIPMENT_STATUS_PACKED = 1;
SHIPMENT_STATUS_SHIPPED = 2;
SHIPMENT_STATUS_DELIVERED = 3;
}
message Shipment {
string tracking_id = 1;
int32 weight_grams = 2;
string destination = 3;
ShipmentStatus status = 4;
oneof payment {
string card_last4 = 5;
string bank_ref = 6;
}
string invoice_ref = 7;
}
Read it from the top. syntax = "proto3" picks the language version. The package is a namespace for names. The enum lists the allowed shipment states; every proto3 enum must start with a value numbered 0, and naming it ..._UNSPECIFIED keeps it from meaning anything real. The Shipment message has seven fields, each with its number. The oneof payment block says that at most one of card_last4 and bank_ref is set at a time. invoice_ref sits outside it on purpose, because Step 6 moves it.
1.2 Compile every version and load several at once
You will keep each schema version in its own folder and compare them, so you need old and new versions of the same message in one Python process. The usual generated module cannot do that: importing a second shipment_pb2 module built from a file with the same name fails with the error duplicate file name shipment.proto. Instead, compile each version into a descriptor set and build classes from it in a private pool. Save this as schemas.py.
"""schemas.py: compile versions of shipment.proto and load several of them into one process."""
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
from google.protobuf.internal import enum_type_wrapper
LAB = Path(__file__).resolve().parent
PROTOS = LAB / "protos"
BUILD = LAB / "build"
def protoc(*args, cwd=None):
"""Run protoc (it ships inside grpcio-tools) and return the finished process."""
cmd = [sys.executable, "-m", "grpc_tools.protoc", *args]
return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
def compile_version(version):
"""Compile protos/<version>/shipment.proto into build/<version>.desc, a FileDescriptorSet."""
BUILD.mkdir(exist_ok=True)
out = BUILD / f"{version}.desc"
result = protoc(f"-I{PROTOS / version}", f"--descriptor_set_out={out}", "--include_imports", "shipment.proto")
if result.returncode:
raise SystemExit(result.stderr)
return out
def load(version):
"""Build the classes for one schema version in a private descriptor pool."""
data = (BUILD / f"{version}.desc").read_bytes()
pool = descriptor_pool.DescriptorPool()
for file_proto in descriptor_pb2.FileDescriptorSet.FromString(data).file:
pool.AddSerializedFile(file_proto.SerializeToString())
return SimpleNamespace(
version=version,
Shipment=message_factory.GetMessageClass(pool.FindMessageTypeByName("shipping.Shipment")),
Status=enum_type_wrapper.EnumTypeWrapper(pool.FindEnumTypeByName("shipping.ShipmentStatus")),
)
compile_version runs the compiler with --descriptor_set_out, which writes the descriptor set, and --include_imports, which would add any imported files. load reads the set into a fresh DescriptorPool and returns a namespace holding the Shipment class and the status enum. Because each pool is separate, a package name like shipping can appear in every version without clashing. A real service would use the generated shipment_pb2 module; step 1.4 checks that the two write the same bytes.
Save this small script as step00_compile.py. It compiles every folder under protos/, so you will rerun it whenever you add or edit a schema.
"""step00_compile.py: compile every schema version under protos/ into build/<version>.desc."""
from importlib.metadata import version
import schemas
print("protobuf", version("protobuf"), "| grpcio-tools", version("grpcio-tools"))
print(schemas.protoc("--version").stdout.strip())
for folder in sorted(p for p in schemas.PROTOS.iterdir() if p.is_dir()):
out = schemas.compile_version(folder.name)
print(f"protos/{folder.name}/shipment.proto -> build/{out.name}")
python step00_compile.py
With only v1 present the output has one schema line. This is the output with all the folders from the later steps in place:
protobuf 7.36.2 | grpcio-tools 1.84.0
libprotoc 35.1
protos/v1/shipment.proto -> build/v1.desc
protos/v2/shipment.proto -> build/v2.desc
protos/v3_oneof/shipment.proto -> build/v3_oneof.desc
protos/v3_rename/shipment.proto -> build/v3_rename.desc
protos/v3_reserved/shipment.proto -> build/v3_reserved.desc
protos/v3_reuse/shipment.proto -> build/v3_reuse.desc
protos/v4_bad/shipment.proto -> build/v4_bad.desc
1.3 Read the bytes without a schema
To see the wire format you need a tiny decoder that knows nothing about Shipment. The protobuf encoding guide explains that each record starts with a tag, which it describes as a varint “formed from the field number and the wire type via the formula (field_number << 3) | wire_type”. A varint is an integer stored seven bits per byte, with the top bit saying that more bytes follow. The low three bits of the tag are the wire type and the rest is the field number. Save this as wiredump.py.
"""wiredump.py: list the fields inside serialized protobuf bytes without knowing the schema."""
WIRE_TYPES = {0: "varint", 1: "fixed64", 2: "len", 5: "fixed32"}
def read_varint(data, pos):
"""Decode one base-128 varint starting at pos; return (value, next position)."""
shift = result = 0
while True:
byte = data[pos]
pos += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result, pos
shift += 7
def fields(data):
"""Yield (field number, wire type name, raw value) for every field in data."""
pos = 0
while pos < len(data):
tag, pos = read_varint(data, pos)
number, wire = tag >> 3, tag & 7
if wire == 0:
value, pos = read_varint(data, pos)
elif wire == 2:
size, pos = read_varint(data, pos)
value, pos = data[pos:pos + size], pos + size
elif wire == 5:
value, pos = int.from_bytes(data[pos:pos + 4], "little"), pos + 4
elif wire == 1:
value, pos = int.from_bytes(data[pos:pos + 8], "little"), pos + 8
else:
raise ValueError(f"unsupported wire type {wire}")
yield number, WIRE_TYPES[wire], value
def dump(data):
"""Print one line per field."""
for number, wire, value in fields(data):
print(f" field {number:>2} {wire:<7} {value!r}")
Now save step01_wire.py. It builds one shipment, prints its bytes and the decoder’s view, asks protoc --decode_raw for a second opinion, and compares against a generated module.
"""step01_wire.py: what a Shipment looks like on the wire."""
import subprocess
import sys
import schemas
import wiredump
v1 = schemas.load("v1")
ship = v1.Shipment(
tracking_id="1Z-0042",
weight_grams=2500,
destination="Oslo",
status=v1.Status.Value("SHIPMENT_STATUS_SHIPPED"),
card_last4="4242",
)
data = ship.SerializeToString()
print(len(data), "bytes")
print(data.hex(" "))
wiredump.dump(data)
print("field names inside the bytes:", any(name in data for name in (b"tracking_id", b"weight_grams", b"destination")))
raw = subprocess.run([sys.executable, "-m", "grpc_tools.protoc", "--decode_raw"], input=data, capture_output=True)
print("protoc --decode_raw says:")
print("\n".join(line for line in raw.stdout.decode().splitlines() if line))
# The usual route is generated code. It must produce the same bytes as the descriptor-set class above.
gen = schemas.LAB / "gen_v1"
gen.mkdir(exist_ok=True)
schemas.protoc(f"-I{schemas.PROTOS / 'v1'}", f"--python_out={gen}", "shipment.proto")
sys.path.insert(0, str(gen))
import shipment_pb2 # noqa: E402
same = shipment_pb2.Shipment(
tracking_id="1Z-0042", weight_grams=2500, destination="Oslo",
status=shipment_pb2.SHIPMENT_STATUS_SHIPPED, card_last4="4242",
).SerializeToString()
print("generated shipment_pb2 module writes the same bytes:", same == data)
python step01_wire.py
26 bytes
0a 07 31 5a 2d 30 30 34 32 10 c4 13 1a 04 4f 73 6c 6f 20 02 2a 04 34 32 34 32
field 1 len b'1Z-0042'
field 2 varint 2500
field 3 len b'Oslo'
field 4 varint 2
field 5 len b'4242'
field names inside the bytes: False
protoc --decode_raw says:
1: "1Z-0042"
2: 2500
3: "Oslo"
4: 2
5: "4242"
generated shipment_pb2 module writes the same bytes: True
Here is the same stream cut into records, so you can check the arithmetic yourself:
| Bytes | Tag | Meaning |
|---|---|---|
0a 07 31 5a 2d 30 30 34 32 |
0a is field 1, length-delimited |
length 7, then the text 1Z-0042 |
10 c4 13 |
10 is field 2, varint |
2500 (c4 carries 68, 13 carries 19 times 128, and 68 plus 2,432 is 2,500) |
1a 04 4f 73 6c 6f |
1a is field 3, length-delimited |
length 4, then Oslo |
20 02 |
20 is field 4, varint |
2, the number of SHIPMENT_STATUS_SHIPPED |
2a 04 34 32 34 32 |
2a is field 5, length-delimited |
length 4, then 4242 |
Three things to take away. First, the line field names inside the bytes: False is the whole story: tracking_id never appears, only the number 1. Second, a field the sender left unset, like bank_ref, costs zero bytes. Third, every record says how long it is, which is why the encoding guide can say that this “allows old parsers to skip over new fields they don’t understand.” That skipping is what makes adding fields safe, as the next step shows.
1.4 Check that generated code agrees
The last two lines of the script generate a normal shipment_pb2.py module with --python_out, build the same message with it, and compare. True means the descriptor-set classes used in the rest of this tutorial write exactly the bytes that generated code writes, so what you measure here should carry over to the generated code in your services. If it prints False, stop and compare your protobuf and grpcio-tools versions with the ones listed above.
Step 2: Make the safe change, adding fields
Version 2 is what a careful edit looks like. Copy protos/v1 to protos/v2 and replace the file with this one.
syntax = "proto3";
package shipping;
enum ShipmentStatus {
SHIPMENT_STATUS_UNSPECIFIED = 0;
SHIPMENT_STATUS_PACKED = 1;
SHIPMENT_STATUS_SHIPPED = 2;
SHIPMENT_STATUS_DELIVERED = 3;
SHIPMENT_STATUS_RETURNED = 4;
}
message Shipment {
string tracking_id = 1;
int32 weight_grams = 2;
string destination = 3;
ShipmentStatus status = 4;
oneof payment {
string card_last4 = 5;
string bank_ref = 6;
string wallet_token = 10;
}
string invoice_ref = 7;
string carrier = 8;
optional int32 insured_cents = 9;
}
Four things changed. A new carrier string took the next free number, 8. A new numeric insured_cents took 9 and is marked optional; Step 3 explains why. A new wallet_token joined the payment oneof with number 10. A new enum value, SHIPMENT_STATUS_RETURNED = 4, was added at the end. No existing number, name or type moved. Run python step00_compile.py again, then save step02_unknown.py.
"""step02_unknown.py: add fields on the writer side and watch an old reader, and old services in the middle, cope."""
from google.protobuf import json_format
import schemas
v1, v2 = schemas.load("v1"), schemas.load("v2")
new = v2.Shipment(tracking_id="1Z-0042", weight_grams=2500, destination="Oslo", carrier="DHL", insured_cents=12000)
wire = new.SerializeToString()
old = v1.Shipment.FromString(wire)
print(f"v1 reader parsed {len(wire)} bytes without an error")
print("it sees:", old.tracking_id, old.weight_grams, old.destination)
try:
old.carrier
except AttributeError as exc:
print("old.carrier ->", type(exc).__name__ + ":", exc)
print("re-serialized bytes identical to the input:", old.SerializeToString() == wire)
def hop_binary(msg):
"""Parse, then serialize again."""
return msg.SerializeToString()
def hop_copy(msg):
"""Copy the whole message with CopyFrom."""
copy = v1.Shipment()
copy.CopyFrom(msg)
return copy.SerializeToString()
def hop_field_by_field(msg):
"""Rebuild the message from the fields this service knows about."""
copy = v1.Shipment(tracking_id=msg.tracking_id, weight_grams=msg.weight_grams,
destination=msg.destination, status=msg.status)
return copy.SerializeToString()
def hop_json(msg):
"""Convert to JSON and back, as a REST gateway might."""
text = json_format.MessageToJson(msg)
return json_format.Parse(text, v1.Shipment()).SerializeToString()
print("what a v2 reader sees after an old v1 service forwards the message:")
for hop in (hop_binary, hop_copy, hop_field_by_field, hop_json):
end = v2.Shipment.FromString(hop(old))
kept = "kept" if end.HasField("insured_cents") else "lost"
print(f" {hop.__name__:<20} carrier={end.carrier!r:<6} insured_cents {kept}")
python step02_unknown.py
v1 reader parsed 26 bytes without an error
it sees: 1Z-0042 2500 Oslo
old.carrier -> AttributeError: carrier
re-serialized bytes identical to the input: True
what a v2 reader sees after an old v1 service forwards the message:
hop_binary carrier='DHL' insured_cents kept
hop_copy carrier='DHL' insured_cents kept
hop_field_by_field carrier='' insured_cents lost
hop_json carrier='' insured_cents lost
What just happened
A new writer sent 26 bytes and an old reader parsed all of them without complaint. It sees the three fields it knows. Asking for carrier fails with an AttributeError, because the class has no such field. The language guide describes this case directly: “old binaries simply ignore the new field when parsing.”
The line re-serialized bytes identical to the input: True is the more interesting result. The old reader did not just ignore the new fields; it kept them. The language guide says that “Proto3 messages preserve unknown fields and include them during parsing and in the serialized output”. Here the bytes came back identical, but the language guide does not promise identical bytes in general, only that the data is preserved.
That preservation matters for services in the middle, such as a gateway or a queue consumer that parses a message and forwards it. The four functions show four ways to forward, and the last block of output shows what a new reader at the end receives. Parsing and re-serializing, or copying with CopyFrom, keeps carrier and insured_cents. Rebuilding the message field by field, or converting to JSON and back, loses both without any error. The language guide lists exactly these two: “Serialize a proto to JSON” and “Iterate over all of the fields in a message to populate a new message” both lose unknown fields, and it recommends “message-oriented APIs, such as CopyFrom() and MergeFrom()”.
Common mistake: a forwarding service that copies the fields it knows about is a data-loss bug waiting for the next schema change, and it will pass every test written before that change. Copy the whole message instead.
Step 3: Decide presence when you add a number
You added insured_cents as optional. Here is what that buys. Save step03_presence.py.
"""step03_presence.py: implicit versus explicit presence when you add a numeric field."""
import schemas
v1, v2 = schemas.load("v1"), schemas.load("v2")
zero_weight = v2.Shipment(weight_grams=0).SerializeToString()
zero_insured = v2.Shipment(insured_cents=0).SerializeToString()
print("weight_grams = 0 (plain field) ->", len(zero_weight), "bytes", zero_weight.hex(" "))
print("insured_cents = 0 (optional field) ->", len(zero_insured), "bytes", zero_insured.hex(" "))
try:
v2.Shipment(weight_grams=0).HasField("weight_grams")
except ValueError as exc:
print("HasField on the plain field raises:", exc)
from_old_writer = v2.Shipment.FromString(v1.Shipment(tracking_id="1Z-0042").SerializeToString())
from_zero_writer = v2.Shipment.FromString(zero_insured)
for label, msg in (("old writer, field unknown to it", from_old_writer), ("new writer, explicit zero", from_zero_writer)):
print(f"{label:<32} insured_cents={msg.insured_cents} HasField={msg.HasField('insured_cents')}")
python step03_presence.py
weight_grams = 0 (plain field) -> 0 bytes
insured_cents = 0 (optional field) -> 2 bytes 48 00
HasField on the plain field raises: Field shipping.Shipment.weight_grams does not have presence.
old writer, field unknown to it insured_cents=0 HasField=False
new writer, explicit zero insured_cents=0 HasField=True
The field presence guide defines the idea: “Field presence is the notion of whether a Protobuf field has a value.” An ordinary proto3 number field has implicit presence, so setting it to 0 and not setting it look the same. The first output line shows the result: weight_grams = 0 writes zero bytes, and asking whether it is present raises an error. An optional field has explicit presence, so a zero is written (48 00, field 9 with value 0) and HasField can tell the cases apart.
The last two lines show why that matters when you add a field. An old writer does not know insured_cents exists, so its messages carry nothing for it, and a new reader sees insured_cents=0. Without explicit presence, the reader cannot tell “this shipment is insured for zero” from “this message predates insurance”. The presence guide’s advice is short: “We recommend adding the optional label for proto3 basic types by default.”
Step 4: Remove a field without causing a disaster
Suppose the team drops weight_grams because the carrier now measures weight, and a later change needs a number for a parcel count. Someone notices that number 2 is free. Create protos/v3_reuse by copying v2 and making this one-line change:
--- v2/shipment.proto
+++ v3_reuse/shipment.proto
@@ -13,5 +13,5 @@
message Shipment {
string tracking_id = 1;
- int32 weight_grams = 2;
+ int32 parcel_count = 2;
string destination = 3;
ShipmentStatus status = 4;
The compiler accepts this, the generated code looks fine, and every unit test that uses only the new schema passes. Run python step00_compile.py, then save and run step04_reuse.py.
"""step04_reuse.py: reuse a field number and nothing fails, which is the problem; then reserve it."""
import schemas
v2, v3 = schemas.load("v2"), schemas.load("v3_reuse")
old_data = v2.Shipment(tracking_id="1Z-0042", weight_grams=2500).SerializeToString()
seen = v3.Shipment.FromString(old_data)
print(f"v2 writer sent weight_grams=2500 -> v3 reader reports parcel_count={seen.parcel_count}")
new_data = v3.Shipment(tracking_id="1Z-0043", parcel_count=3).SerializeToString()
seen = v2.Shipment.FromString(new_data)
print(f"v3 writer sent parcel_count=3 -> v2 reader reports weight_grams={seen.weight_grams}")
# Now the fix: v3_reserved says "reserved 2;" and "reserved "weight_grams";". Try to reuse them anyway.
source = (schemas.PROTOS / "v3_reserved" / "shipment.proto").read_text()
attempts = {
"number": source.replace("parcel_count = 11;", "parcel_count = 2;"),
"name": source.replace("int32 parcel_count = 11;", "int32 weight_grams = 11;"),
}
for label, text in attempts.items():
folder = schemas.BUILD / f"reuse_{label}"
folder.mkdir(parents=True, exist_ok=True)
(folder / "shipment.proto").write_text(text)
result = schemas.protoc("-I.", "--descriptor_set_out=x.desc", "shipment.proto", cwd=folder)
print(f"reusing the reserved {label}: protoc exit code {result.returncode}")
print(" " + result.stderr.strip())
python step04_reuse.py
v2 writer sent weight_grams=2500 -> v3 reader reports parcel_count=2500
v3 writer sent parcel_count=3 -> v2 reader reports weight_grams=3
reusing the reserved number: protoc exit code 1
shipment.proto:14:12: Field "parcel_count" uses reserved number 2.
shipment.proto:14:12: Suggested field numbers for shipping.Shipment: 11
reusing the reserved name: protoc exit code 1
shipment.proto:27:9: Field name "weight_grams" is reserved.
The first two lines are the disaster. A message written by the old schema with a weight of 2,500 grams is read by the new schema as a parcel count of 2,500. A message written by the new schema with three parcels is read by the old schema as a weight of three grams. Nothing raised an error in either direction, because field 2 is an int32 in both versions and the bytes are valid for both. The best-practices guide’s rule is blunt: “Never re-use a tag number. It messes up deserialization.” It also explains why “nobody uses it any more” is not a safe judgement: “If the change was live ever, there could be serialized versions of your proto in a log somewhere.”
The fix: reserve numbers and names
The best-practices guide says: “When you delete a field that’s no longer used, reserve its tag number so that no one accidentally re-uses it in the future.” Create protos/v3_reserved from v2 with this change, which reserves both the number and the name and gives the parcel count a fresh number:
--- v2/shipment.proto
+++ v3_reserved/shipment.proto
@@ -12,6 +12,7 @@
message Shipment {
+ reserved 2;
+ reserved "weight_grams";
string tracking_id = 1;
- int32 weight_grams = 2;
string destination = 3;
ShipmentStatus status = 4;
@@ -24,3 +25,4 @@
string carrier = 8;
optional int32 insured_cents = 9;
+ int32 parcel_count = 11;
}
The rest of the output shows what a reserved number buys you. When someone edits v3_reserved to use number 2 again, or the name weight_grams again, the compiler refuses, with the line and column of the mistake. That is the whole mechanism: reserved turns a silent runtime corruption into a compile error. It costs nothing in the bytes.
Common mistake: reserving only the number. Names matter for JSON and text formats, which Step 7 covers, so reserve both.
Step 5: Know which type changes survive
Changing a field’s type is the edit that most often looks harmless. The best-practices guide’s own advice: “Almost never change the type of a field; it’ll mess up deserialization, same as re-using a tag number.” It also admits that some changes parse, and that “changing a field’s type can be difficult to roll out safely even when the new schema can successfully parse old data.” To see exactly which ones are which, you need a way to try many type pairs quickly. This helper builds a throwaway message class with one field of any scalar type. Save it as dyn.py.
"""dyn.py: build a one-field message class on the fly, so a script can try many type changes."""
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
F = descriptor_pb2.FieldDescriptorProto
SCALARS = {
name: getattr(F, "TYPE_" + name.upper())
for name in ("int32", "int64", "uint32", "uint64", "sint32", "sint64", "bool", "fixed32",
"sfixed32", "fixed64", "sfixed64", "float", "double", "string", "bytes")
}
def one_field(type_name, repeated=False):
"""Return a proto3 message class with one field, number 1, of the given scalar type."""
file_proto = descriptor_pb2.FileDescriptorProto(name="box.proto", package="demo", syntax="proto3")
message = file_proto.message_type.add(name="Box")
message.field.add(name="value", number=1, type=SCALARS[type_name],
label=F.LABEL_REPEATED if repeated else F.LABEL_OPTIONAL)
pool = descriptor_pool.DescriptorPool()
pool.AddSerializedFile(file_proto.SerializeToString())
return message_factory.GetMessageClass(pool.FindMessageTypeByName("demo.Box"))
Now save step05_matrix.py. Each row names a writer type and a value, a reader type, and prints what the reader ends up with. A reader that parses the bytes but finds a different wire type for field 1 keeps the record as an unknown field, so the script checks for that case and reports it.
"""step05_matrix.py: write a value with one field type, read it with another, and see what survives."""
from google.protobuf.message import DecodeError
import dyn
def bytes_of(*values):
return bytes(values)
# (writer type, writer repeated, value, reader type, reader repeated)
CASES = [
("int32", False, -5, "int64", False),
("int64", False, 3_000_000_000, "int32", False),
("int32", False, -5, "uint32", False),
("int32", False, 256, "bool", False),
("sint32", False, -3, "sint64", False),
("string", False, "h" + chr(0xE9) + "llo", "bytes", False),
("bytes", False, bytes_of(0xFF, 0xFE), "string", False),
("fixed32", False, 4_000_000_000, "sfixed32", False),
("int32", False, 5, "sint32", False),
("sint32", False, -3, "int32", False),
("int32", False, 5, "fixed32", False),
("int32", False, 5, "string", False),
("string", False, "abc", "int32", False),
("int32", True, [1, 2, 3], "int32", False),
("string", True, ["a", "b", "c"], "string", False),
("int32", False, 5, "int32", True),
("string", False, "x", "string", True),
]
def show(value):
return ascii(list(value) if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)) else value)
def try_change(writer, w_rep, value, reader, r_rep):
data = dyn.one_field(writer, w_rep)(value=value).SerializeToString()
try:
got = dyn.one_field(reader, r_rep).FromString(data)
except DecodeError as exc:
return f"parse error: {exc}"
if got.ListFields():
return show(got.value)
return "ignored: kept as an unknown field" if got.SerializeToString() else "nothing"
for writer, w_rep, value, reader, r_rep in CASES:
left = ("repeated " if w_rep else "") + writer
right = ("repeated " if r_rep else "") + reader
print(f"{left:<15} {show(value):>14} -> {right:<15} {try_change(writer, w_rep, value, reader, r_rep)}")
python step05_matrix.py
int32 -5 -> int64 -5
int64 3000000000 -> int32 -1294967296
int32 -5 -> uint32 4294967291
int32 256 -> bool True
sint32 -3 -> sint64 -3
string 'h\xe9llo' -> bytes b'h\xc3\xa9llo'
bytes b'\xff\xfe' -> string parse error: Error parsing message with type 'demo.Box': String field had bad UTF-8
fixed32 4000000000 -> sfixed32 -294967296
int32 5 -> sint32 -3
sint32 -3 -> int32 5
int32 5 -> fixed32 ignored: kept as an unknown field
int32 5 -> string ignored: kept as an unknown field
string 'abc' -> int32 ignored: kept as an unknown field
repeated int32 [1, 2, 3] -> int32 ignored: kept as an unknown field
repeated string ['a', 'b', 'c'] -> string 'c'
int32 5 -> repeated int32 [5]
string 'x' -> repeated string ['x']
Reading the table
The first eight rows are changes the language guide calls compatible, and each one shows its cost. Widening int32 to int64 is fine. The reverse loses data: a 3 billion value read as int32 becomes -1,294,967,296, which is what the language guide means by “a client that reads it as an int32 will discard the high order bits of the number.” A negative int32 read as uint32 becomes 4,294,967,291, and 256 read as bool is True, the same as any other non-zero value would be under the C++ cast rule the language guide cites. fixed32 and sfixed32 share a layout, so a large value changes sign. string and bytes share a layout too, but only while the bytes are valid text; the language guide says they are compatible “as long as the bytes are valid UTF-8”, and the seventh row shows the parser rejecting the bytes ff fe outright.
The next five rows are changes that look similar but are not compatible. int32 and sint32 both hold signed integers, yet sint32 uses ZigZag encoding, which maps 5 to 10 and -3 to 5 on the wire. A value written as int32 5 therefore reads back as sint32 -3, and the reverse turns -3 into 5. No error, no warning. The language guide says “sint32 and sint64 are compatible with each other but are not compatible with the other integer types.” Changing a number to fixed32 or string, or a string to a number, changes the wire type, so the reader files the record as unknown and the field silently reads as its default.
The last four rows are about repeating. The best-practices guide warns “For numeric proto3 fields and proto2 packed fields, going from repeated to scalar will lose all data in that field.” The row shows it: [1, 2, 3] read as a single int32 is ignored, because numeric repeated fields are packed into one length-delimited record. A repeated string read as a singular string keeps only 'c', the last value. Going the other way is gentler, because the encoding guide requires parsers to accept both layouts: “Protobuf parsers must be able to parse repeated fields that were compiled as packed as if they were not packed, and vice versa.”
Rule of thumb: treat any type change as a new field. Add a new field with a new number, move writers to it, then reserve the old one. If you must widen a type, follow the language guide’s advice to “manage the roll out to your system carefully”: keep writing values that fit the old type until every reader has the new schema.
Step 6: Enums and oneofs
Enums and oneofs are where “safe” changes still surprise application code. Create protos/v3_oneof from v2 with this change, which moves invoice_ref into the existing payment oneof. This is one of the two edits the language guide lists as wire-unsafe.
--- v2/shipment.proto
+++ v3_oneof/shipment.proto
@@ -20,6 +20,6 @@
string bank_ref = 6;
string wallet_token = 10;
+ string invoice_ref = 7;
}
- string invoice_ref = 7;
string carrier = 8;
optional int32 insured_cents = 9;
Run python step00_compile.py, then save and run step06_enum_oneof.py.
"""step06_enum_oneof.py: new enum values, new oneof members, and fields moved into a oneof."""
import schemas
v1, v2, v3 = schemas.load("v1"), schemas.load("v2"), schemas.load("v3_oneof")
returned = v2.Shipment(tracking_id="1Z-0042", status=v2.Status.Value("SHIPMENT_STATUS_RETURNED"), wallet_token="tok_9f2")
wire = returned.SerializeToString()
old = v1.Shipment.FromString(wire)
print("v1 reader: status =", old.status, "| payment variant =", old.WhichOneof("payment"))
try:
v1.Status.Name(old.status)
except ValueError as exc:
print("v1.Status.Name(4) ->", type(exc).__name__ + ":", exc)
def label(status_enum, number):
"""Name of a status, tolerant of numbers this build has never heard of."""
try:
return status_enum.Name(number)
except ValueError:
return f"UNKNOWN({number})"
print("tolerant label:", label(v1.Status, old.status))
end = v2.Shipment.FromString(old.SerializeToString())
print("after a v1 service forwards it: status =", v2.Status.Name(end.status), "| wallet_token =", end.wallet_token)
both = v2.Shipment(card_last4="4242", invoice_ref="INV-77").SerializeToString()
print("v2 writer sets card_last4 and invoice_ref:", len(both), "bytes")
moved = v3.Shipment.FromString(both)
print("v3 reader (invoice_ref moved into payment): payment =", moved.WhichOneof("payment"), "| card_last4 =", repr(moved.card_last4))
python step06_enum_oneof.py
v1 reader: status = 4 | payment variant = None
v1.Status.Name(4) -> ValueError: Enum ShipmentStatus has no name defined for value 4
tolerant label: UNKNOWN(4)
after a v1 service forwards it: status = SHIPMENT_STATUS_RETURNED | wallet_token = tok_9f2
v2 writer sets card_last4 and invoice_ref: 14 bytes
v3 reader (invoice_ref moved into payment): payment = invoice_ref | card_last4 = ''
New enum values and new oneof members
The language guide says “Adding additional values to an enum is safe”, and the bytes bear that out, but an old program still has to do something with a number it has never heard of. Proto3 enums are open, and the language guide says “unrecognized proto3 enum values will be preserved in the message”, so the old reader keeps the unknown value 4 as a plain integer, and Status.Name(4) raises ValueError. A switch or if chain with a final else: raise will crash on the first RETURNED shipment. The tolerant label function above turns it into UNKNOWN(4). The best-practices guide confirms the behaviour you saw next: proto enums “can round-trip (deserialize, serialize) an unknown enum value”, so a v1 service that forwards the message does not damage it.
The same thinking applies to oneofs. The new writer set wallet_token, which the old reader has never heard of, so WhichOneof("payment") returned None. To the old code it looks like “no payment method”, which may be a validation error in a checkout flow. Code that reads a oneof needs a branch for “something I do not recognize”, not just for “nothing”.
Moving a field into an existing oneof
The last two lines show the language guide’s warning, “Moving fields into an existing oneof is not safe”, in action. The v2 writer sets card_last4 and invoice_ref together, which is legal because invoice_ref is not in the oneof. The v3 reader sees both on the wire and keeps only invoice_ref; the card number is cleared without an error. The language guide states the rule for a oneof with several values: “the last set value as determined by the order in the proto will overwrite all previous ones.” In this run invoice_ref is last both in the oneof and on the wire, so the measurement fits that rule but does not separate the two orderings.
Step 7: A rename is free in binary and expensive in JSON
Everything so far used the binary format, where only numbers matter. Many systems also write protobuf as JSON, for REST gateways, logs or stored documents, and JSON uses the names. Create protos/v3_rename from v2 with this change:
--- v2/shipment.proto
+++ v3_rename/shipment.proto
@@ -14,5 +14,5 @@
string tracking_id = 1;
int32 weight_grams = 2;
- string destination = 3;
+ string delivery_address = 3;
ShipmentStatus status = 4;
oneof payment {
Run python step00_compile.py, then save and run step07_json.py.
"""step07_json.py: a rename is free in binary and expensive in JSON."""
from google.protobuf import json_format
import schemas
v1, v2, v3 = schemas.load("v1"), schemas.load("v2"), schemas.load("v3_rename")
def first_line(exc):
"""The first line of a ParseError, without the list of available fields."""
return str(exc).split("\n")[0]
msg = v2.Shipment(tracking_id="1Z-0042", destination="Oslo", carrier="DHL")
wire = msg.SerializeToString()
renamed = v3.Shipment.FromString(wire)
print("binary: v3 reader reads the old bytes, delivery_address =", repr(renamed.delivery_address))
print("binary: v3 writes the same bytes again:", renamed.SerializeToString() == wire)
text = json_format.MessageToJson(msg, indent=None)
print("JSON written by v2:", text)
try:
json_format.Parse(text, v3.Shipment())
except json_format.ParseError as exc:
print("v3 parses it ->", type(exc).__name__ + ":", first_line(exc))
lenient = json_format.Parse(text, v3.Shipment(), ignore_unknown_fields=True)
print("with ignore_unknown_fields=True: delivery_address =", repr(lenient.delivery_address), "(the data is gone, silently)")
print("a v1 service meets JSON from a v2 writer:")
returned = v2.Shipment(status=v2.Status.Value("SHIPMENT_STATUS_RETURNED"))
for label, source in (("new field carrier", msg), ("new enum value", returned)):
text = json_format.MessageToJson(source, indent=None)
try:
json_format.Parse(text, v1.Shipment())
print(f" {label}: parsed")
except json_format.ParseError as exc:
print(f" {label}: {text}")
print(f" ParseError: {first_line(exc)}")
as_number = json_format.MessageToJson(returned, indent=None, use_integers_for_enums=True)
parsed = json_format.Parse(as_number, v1.Shipment())
print("the same enum written as a number:", as_number, "-> v1 parses it, status =", parsed.status)
python step07_json.py
binary: v3 reader reads the old bytes, delivery_address = 'Oslo'
binary: v3 writes the same bytes again: True
JSON written by v2: {"trackingId": "1Z-0042", "destination": "Oslo", "carrier": "DHL"}
v3 parses it -> ParseError: Message type "shipping.Shipment" has no field named "destination" at "Shipment".
with ignore_unknown_fields=True: delivery_address = '' (the data is gone, silently)
a v1 service meets JSON from a v2 writer:
new field carrier: {"trackingId": "1Z-0042", "destination": "Oslo", "carrier": "DHL"}
ParseError: Message type "shipping.Shipment" has no field named "carrier" at "Shipment".
new enum value: {"status": "SHIPMENT_STATUS_RETURNED"}
ParseError: Failed to parse status field: Invalid enum value SHIPMENT_STATUS_RETURNED for enum type shipping.ShipmentStatus at Shipment.status.
the same enum written as a number: {"status": 4} -> v1 parses it, status = 4
What the output shows
In binary the rename is invisible: the new reader reads Oslo from the old bytes and writes the same bytes back. In JSON it is a break. The v2 writer produced "destination", and the v3 parser rejects it because it has no field with that name. Passing ignore_unknown_fields=True makes the error go away, but only by dropping the data, so delivery_address comes back empty. That is the trap: the flag turns a loud failure into a silent one.
The same happens for additions. A v1 service parsing v2 JSON fails on the new carrier field and on the new enum name SHIPMENT_STATUS_RETURNED. This matches the best-practices guide: “deserialization of Protobuf messages in these formats using old code will fail when a field or enum value is renamed, or when a new field or enum value or extension is added.” Its advice is “Use binary serialization when possible for data interchange, and use text format for human editing and debugging only.” It adds: “If you use protos converted to JSON in your API or for storing data, you may not be able to safely rename fields or enums at all.”
The last line shows an escape hatch for enums: written as the number 4, the old parser accepted it and kept the value. Whether to depend on that is a design decision for your API, and the documented advice above is the safer default. The practical rule is that a rename needs a human to confirm that no JSON consumer exists, which leads to the gate.
Step 8: Build a gate that refuses unsafe edits
You now have a list of rules, and lists of rules get forgotten. A schema gate checks them for you on every change. It loads the old and new descriptor sets, walks both, and reports each difference as an error, a waived error or a note. It exits with 1 if any error remains, which is all a CI system needs.
8.1 The rules
| Rule | Level | What it catches |
|---|---|---|
FIELD_REMOVED_UNRESERVED |
error | a field number disappeared without reserved, including a renumbering |
FIELD_NAME_CHANGED |
error, waivable | the same number has a new name |
FIELD_TYPE_CHANGED |
error | a type change that is not wire-compatible |
FIELD_TYPE_COMPATIBLE_CHANGE |
error, waivable | a wire-compatible change, such as int32 to int64, that needs a rollout plan |
FIELD_REPEATED_CHANGED |
error | singular to repeated, or back |
FIELD_MOVED_INTO_ONEOF |
error | existing fields moved into an existing oneof, or two or more into a new one |
ENUM_VALUE_REMOVED_UNRESERVED |
error | an enum number disappeared without reserved |
ENUM_VALUE_NAME_CHANGED |
error, waivable | an enum number has a new name, which breaks JSON |
RESERVED_REMOVED |
error | a reservation was deleted |
FIELD_ADDED, ENUM_VALUE_ADDED, *_REMOVED_RESERVED |
note | safe changes, printed so a reviewer can see what the gate saw |
One rule deserves a pause. FIELD_NAME_CHANGED is an error, not a warning. Look back at Step 4: reusing field 2 as parcel_count keeps the number and the type and changes only the name. That is exactly what a harmless rename looks like to any program comparing two schemas. Only a person can tell the two apart, so the gate stops until someone says “this is a pure rename” with an explicit waiver.
8.2 The code
Save this as protogate.py. It is shown in four pieces. The first holds the type groups, which come straight from the language guide’s list of compatible changes, and the helpers that index a descriptor set and read reserved ranges.
"""protogate.py: compare two versions of a .proto schema and fail on changes that break old readers or writers.
Usage:
python protogate.py OLD NEW [--waive RULE:full.name ...] OLD and NEW are .proto or .desc files
python protogate.py --git REV PATH [--waive RULE:full.name] OLD is PATH as it was at git revision REV
"""
import argparse
import subprocess
import sys
import tempfile
from collections import namedtuple
from pathlib import Path
from google.protobuf import descriptor_pb2
FIELD = descriptor_pb2.FieldDescriptorProto
Finding = namedtuple("Finding", "level rule path text")
# Types that share a wire encoding, from "Binary Wire-compatible Changes" in the proto3 guide.
COMPATIBLE = [
{"int32", "uint32", "int64", "uint64", "bool"},
{"sint32", "sint64"},
{"string", "bytes"},
{"bytes", "message"},
{"fixed32", "sfixed32"},
{"fixed64", "sfixed64"},
{"enum", "int32"}, {"enum", "uint32"}, {"enum", "int64"}, {"enum", "uint64"},
]
def kind(field):
"""Lower-case type name such as 'int32', 'string', 'message' or 'enum'."""
return FIELD.Type.Name(field.type).removeprefix("TYPE_").lower()
def compatible(a, b):
return any(a in group and b in group for group in COMPATIBLE)
def index(descriptor_set):
"""Map full names to message and enum descriptors, nested ones included."""
messages, enums = {}, {}
def join(prefix, name):
return f"{prefix}.{name}" if prefix else name
def visit(prefix, message):
full = join(prefix, message.name)
messages[full] = message
for enum in message.enum_type:
enums[join(full, enum.name)] = enum
for nested in message.nested_type:
visit(full, nested)
for file_proto in descriptor_set.file:
for message in file_proto.message_type:
visit(file_proto.package, message)
for enum in file_proto.enum_type:
enums[join(file_proto.package, enum.name)] = enum
return messages, enums
def in_ranges(number, ranges, end_is_inclusive):
"""Is number inside one of the reserved ranges? Message ranges end before 'end', enum ranges at 'end'."""
return any(r.start <= number and (number <= r.end if end_is_inclusive else number < r.end) for r in ranges)
def real_oneof(message, field):
"""Name of the oneof a field belongs to, ignoring the hidden oneof that proto3 'optional' creates."""
if field.HasField("oneof_index") and not field.proto3_optional:
return message.oneof_decl[field.oneof_index].name
return None
Two details are worth knowing. Message reserved ranges end before their end value and enum ranges end at it, so in_ranges takes a flag. And ranges are compared as ranges, never expanded into sets, because reserved 100 to max; would otherwise mean half a billion numbers. A test below checks that. The second piece compares messages.
# protogate.py (continued)
def reserved_removed(name, old, new, inclusive, out):
for r in old.reserved_range:
if not any(n.start <= r.start and r.end <= n.end for n in new.reserved_range):
last = r.end if inclusive else r.end - 1
out.append(Finding("ERROR", "RESERVED_REMOVED", name, f"numbers {r.start} to {last} are no longer reserved"))
for reserved in old.reserved_name:
if reserved not in new.reserved_name:
out.append(Finding("ERROR", "RESERVED_REMOVED", name, f"the name '{reserved}' is no longer reserved"))
def compare_message(name, old, new, out):
old_fields = {f.number: f for f in old.field}
new_fields = {f.number: f for f in new.field}
new_numbers = {f.name: f.number for f in new.field}
for number, before in sorted(old_fields.items()):
path = f"{name}.{before.name}"
after = new_fields.get(number)
if after is None:
if in_ranges(number, new.reserved_range, False):
out.append(Finding("NOTE", "FIELD_REMOVED_RESERVED", path, f"field {number} removed and its number is reserved"))
else:
hint = ""
if before.name in new_numbers:
hint = f" ({before.name} now has number {new_numbers[before.name]}: renumbering is a removal plus an addition)"
out.append(Finding("ERROR", "FIELD_REMOVED_UNRESERVED", path, f"field {number} is gone but not reserved; add 'reserved {number};'{hint}"))
continue
if after.name != before.name:
out.append(Finding("ERROR", "FIELD_NAME_CHANGED", path, f"field {number} was '{before.name}' and is now '{after.name}'; a rename and a number reuse look the same in a diff"))
old_kind, new_kind = kind(before), kind(after)
if old_kind != new_kind:
if compatible(old_kind, new_kind):
out.append(Finding("ERROR", "FIELD_TYPE_COMPATIBLE_CHANGE", path, f"{old_kind} to {new_kind} parses, but values can be cut or changed during a rollout"))
else:
out.append(Finding("ERROR", "FIELD_TYPE_CHANGED", path, f"{old_kind} to {new_kind} is not wire-compatible"))
elif old_kind in ("message", "enum") and before.type_name != after.type_name:
out.append(Finding("ERROR", "FIELD_TYPE_CHANGED", path, f"{before.type_name} to {after.type_name}"))
if (before.label == FIELD.LABEL_REPEATED) != (after.label == FIELD.LABEL_REPEATED):
out.append(Finding("ERROR", "FIELD_REPEATED_CHANGED", path, "changed between singular and repeated"))
for number, after in sorted(new_fields.items()):
if number not in old_fields:
out.append(Finding("NOTE", "FIELD_ADDED", f"{name}.{after.name}", f"new field {number} ({kind(after)}); old readers keep it as an unknown field"))
joined = {}
for number, before in old_fields.items():
after = new_fields.get(number)
if after is not None:
target = real_oneof(new, after)
if target is not None and target != real_oneof(old, before):
joined.setdefault(target, []).append(after.name)
old_oneofs = {decl.name for decl in old.oneof_decl}
for oneof, names in joined.items():
if oneof in old_oneofs or len(names) > 1:
out.append(Finding("ERROR", "FIELD_MOVED_INTO_ONEOF", f"{name}.{oneof}", f"existing field(s) {', '.join(names)} moved into the oneof; old writers can set several and only the last one survives"))
reserved_removed(name, old, new, False, out)
compare_message walks every field number in the old message. A missing number is a removal, and the hint about a renumbered field helps the author notice that moving a field to a new number is a removal plus an addition. A present number is compared for name, type and cardinality. The final loop finds fields that joined a oneof. The third piece does the same for enums and ties the comparison together.
# protogate.py (continued)
def compare_enum(name, old, new, out):
names_at = {}
for value in new.value:
names_at.setdefault(value.number, []).append(value.name)
numbers_of = {value.name: value.number for value in new.value}
for value in old.value:
path = f"{name}.{value.name}"
names = names_at.get(value.number)
if names is None:
if in_ranges(value.number, new.reserved_range, True):
out.append(Finding("NOTE", "ENUM_VALUE_REMOVED_RESERVED", path, f"value {value.number} removed and reserved"))
else:
hint = f" ({value.name} now has number {numbers_of[value.name]})" if value.name in numbers_of else ""
out.append(Finding("ERROR", "ENUM_VALUE_REMOVED_UNRESERVED", path, f"value {value.number} is gone but not reserved{hint}"))
elif value.name not in names:
out.append(Finding("ERROR", "ENUM_VALUE_NAME_CHANGED", path, f"number {value.number} was {value.name} and is now {names[0]}; JSON and text format use the names"))
known = {value.number for value in old.value}
for value in new.value:
if value.number not in known:
out.append(Finding("NOTE", "ENUM_VALUE_ADDED", f"{name}.{value.name}", f"new value {value.number}; old readers see the bare number, so code needs a default branch"))
reserved_removed(name, old, new, True, out)
def compare(old_set, new_set):
"""Return the findings for changing old_set into new_set."""
out = []
old_messages, old_enums = index(old_set)
new_messages, new_enums = index(new_set)
for name, old in old_messages.items():
if name in new_messages:
compare_message(name, old, new_messages[name], out)
for name, old in old_enums.items():
if name in new_enums:
compare_enum(name, old, new_enums[name], out)
return out
The last piece is the plumbing: waivers, compiling a .proto file, loading either kind of input, reading the old version from git, and the command line.
# protogate.py (continued)
def apply_waivers(findings, waivers):
"""Turn findings named as RULE:full.name into WAIVED."""
waived = set(waivers)
return [f._replace(level="WAIVED") if f"{f.rule}:{f.path}" in waived else f for f in findings]
def compile_proto(path):
"""Compile one .proto file into a FileDescriptorSet with the protoc inside grpcio-tools."""
path = Path(path).resolve()
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "set.desc"
cmd = [sys.executable, "-m", "grpc_tools.protoc", f"-I{path.parent}", f"--descriptor_set_out={out}", "--include_imports", path.name]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode:
raise SystemExit(result.stderr.strip())
return descriptor_pb2.FileDescriptorSet.FromString(out.read_bytes())
def load_set(path):
if str(path).endswith(".proto"):
return compile_proto(path)
return descriptor_pb2.FileDescriptorSet.FromString(Path(path).read_bytes())
def compile_git(rev, path):
"""Compile PATH as it was at git revision REV, or return None when the file did not exist there."""
known = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}"], capture_output=True)
if known.returncode:
raise SystemExit(f"git revision {rev} not found; fetch it before running the gate")
shown = subprocess.run(["git", "show", f"{rev}:{path}"], capture_output=True)
if shown.returncode:
return None
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / Path(path).name
target.write_bytes(shown.stdout)
return compile_proto(target)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("paths", nargs="+")
parser.add_argument("--git", metavar="REV")
parser.add_argument("--waive", action="append", default=[], metavar="RULE:full.name")
args = parser.parse_args(argv)
if args.git:
old_set, new_set = compile_git(args.git, args.paths[0]), load_set(args.paths[0])
if old_set is None:
print(f"{args.paths[0]} does not exist at {args.git}: nothing to compare")
return 0
else:
old_set, new_set = load_set(args.paths[0]), load_set(args.paths[1])
findings = apply_waivers(compare(old_set, new_set), args.waive)
rank = {"ERROR": 0, "WAIVED": 1, "NOTE": 2}
for f in sorted(findings, key=lambda f: rank[f.level]):
print(f"{f.level:<6} {f.rule:<30} {f.path}: {f.text}")
counts = {level: sum(f.level == level for f in findings) for level in rank}
print(f"{counts['ERROR']} error(s), {counts['WAIVED']} waived, {counts['NOTE']} note(s)")
return 1 if counts["ERROR"] else 0
if __name__ == "__main__":
sys.exit(main())
Notice that compile_git stops with an error when the git revision itself is missing, and only treats a missing file as nothing to compare. A shallow CI checkout can lack origin/main, and a gate that quietly passes in that situation protects nothing.
8.3 Run it on the schemas from the earlier steps
Save step08_gate.py, which runs the gate over six pairs.
"""step08_gate.py: run protogate.py over the schema pairs from the earlier steps."""
import subprocess
import sys
from pathlib import Path
LAB = Path(__file__).resolve().parent
def gate(old, new, *extra):
cmd = [sys.executable, "protogate.py", f"build/{old}.desc", f"build/{new}.desc", *extra]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=LAB)
print(f"$ python protogate.py build/{old}.desc build/{new}.desc {' '.join(extra)}".rstrip())
print(result.stdout.rstrip())
print(f"exit code {result.returncode}\n")
gate("v1", "v2")
gate("v2", "v3_reserved")
gate("v2", "v3_reuse")
gate("v2", "v3_rename")
gate("v2", "v3_rename", "--waive", "FIELD_NAME_CHANGED:shipping.Shipment.destination")
gate("v2", "v4_bad")
First you need the last schema, a deliberately bad one that combines several mistakes. Create protos/v4_bad with this file, run python step00_compile.py, and then run the step.
syntax = "proto3";
package shipping;
enum ShipmentStatus {
SHIPMENT_STATUS_UNSPECIFIED = 0;
SHIPMENT_STATUS_READY = 1;
SHIPMENT_STATUS_SHIPPED = 2;
SHIPMENT_STATUS_RETURNED = 4;
}
message Shipment {
string tracking_id = 12;
sint32 weight_grams = 2;
string delivery_address = 3;
ShipmentStatus status = 4;
oneof payment {
string card_last4 = 5;
string bank_ref = 6;
string wallet_token = 10;
string invoice_ref = 7;
}
string carrier = 8;
repeated int32 insured_cents = 9;
}
python step08_gate.py
The first two runs are the safe changes from Steps 2 and 4.
$ python protogate.py build/v1.desc build/v2.desc
NOTE FIELD_ADDED shipping.Shipment.carrier: new field 8 (string); old readers keep it as an unknown field
NOTE FIELD_ADDED shipping.Shipment.insured_cents: new field 9 (int32); old readers keep it as an unknown field
NOTE FIELD_ADDED shipping.Shipment.wallet_token: new field 10 (string); old readers keep it as an unknown field
NOTE ENUM_VALUE_ADDED shipping.ShipmentStatus.SHIPMENT_STATUS_RETURNED: new value 4; old readers see the bare number, so code needs a default branch
0 error(s), 0 waived, 4 note(s)
exit code 0
$ python protogate.py build/v2.desc build/v3_reserved.desc
NOTE FIELD_REMOVED_RESERVED shipping.Shipment.weight_grams: field 2 removed and its number is reserved
NOTE FIELD_ADDED shipping.Shipment.parcel_count: new field 11 (int32); old readers keep it as an unknown field
0 error(s), 0 waived, 2 note(s)
exit code 0
Both exit with 0. Adding fields and an enum value produces only notes, and removing weight_grams with a reservation produces a note that it was reserved. Now the two edits that looked fine in code review.
$ python protogate.py build/v2.desc build/v3_reuse.desc
ERROR FIELD_NAME_CHANGED shipping.Shipment.weight_grams: field 2 was 'weight_grams' and is now 'parcel_count'; a rename and a number reuse look the same in a diff
1 error(s), 0 waived, 0 note(s)
exit code 1
The number reuse is caught, as a name change on field 2. Exit code 1. A rename is blocked in the same way, and the next two runs show the waiver flow.
$ python protogate.py build/v2.desc build/v3_rename.desc
ERROR FIELD_NAME_CHANGED shipping.Shipment.destination: field 3 was 'destination' and is now 'delivery_address'; a rename and a number reuse look the same in a diff
1 error(s), 0 waived, 0 note(s)
exit code 1
$ python protogate.py build/v2.desc build/v3_rename.desc --waive FIELD_NAME_CHANGED:shipping.Shipment.destination
WAIVED FIELD_NAME_CHANGED shipping.Shipment.destination: field 3 was 'destination' and is now 'delivery_address'; a rename and a number reuse look the same in a diff
0 error(s), 1 waived, 0 note(s)
exit code 0
The unwaived run fails and the waived run passes. The waiver names the rule and the field exactly, so it can live in a reviewed file or a pull request description rather than in a global switch. Finally the kitchen-sink schema.
$ python protogate.py build/v2.desc build/v4_bad.desc
ERROR FIELD_REMOVED_UNRESERVED shipping.Shipment.tracking_id: field 1 is gone but not reserved; add 'reserved 1;' (tracking_id now has number 12: renumbering is a removal plus an addition)
ERROR FIELD_TYPE_CHANGED shipping.Shipment.weight_grams: int32 to sint32 is not wire-compatible
ERROR FIELD_NAME_CHANGED shipping.Shipment.destination: field 3 was 'destination' and is now 'delivery_address'; a rename and a number reuse look the same in a diff
ERROR FIELD_REPEATED_CHANGED shipping.Shipment.insured_cents: changed between singular and repeated
ERROR FIELD_MOVED_INTO_ONEOF shipping.Shipment.payment: existing field(s) invoice_ref moved into the oneof; old writers can set several and only the last one survives
ERROR ENUM_VALUE_NAME_CHANGED shipping.ShipmentStatus.SHIPMENT_STATUS_PACKED: number 1 was SHIPMENT_STATUS_PACKED and is now SHIPMENT_STATUS_READY; JSON and text format use the names
ERROR ENUM_VALUE_REMOVED_UNRESERVED shipping.ShipmentStatus.SHIPMENT_STATUS_DELIVERED: value 3 is gone but not reserved
NOTE FIELD_ADDED shipping.Shipment.tracking_id: new field 12 (string); old readers keep it as an unknown field
7 error(s), 0 waived, 1 note(s)
exit code 1
Seven errors, one for each mistake: the renumbered tracking_id (field 1 is gone and not reserved), the int32 to sint32 change, the rename, insured_cents turned repeated, invoice_ref moved into the oneof, one renamed enum value and one removed enum value. Each message says what to do next.
8.4 Test the gate itself
A gate that has never been shown a bad schema may be passing everything. Save test_protogate.py. Each test writes two tiny schemas, compiles them with the real compiler, and asserts which rules fire.
"""test_protogate.py: every rule in protogate.py, checked against small schemas compiled with protoc."""
import time
import protogate
HEADER = 'syntax = "proto3";\npackage t;\n'
def schema(tmp_path, name, body):
folder = tmp_path / name
folder.mkdir(exist_ok=True)
(folder / "t.proto").write_text(HEADER + body)
return protogate.compile_proto(folder / "t.proto")
def run(tmp_path, old, new, waive=()):
found = protogate.compare(schema(tmp_path, "old", old), schema(tmp_path, "new", new))
return protogate.apply_waivers(found, list(waive))
def errors(found):
return sorted(f.rule for f in found if f.level == "ERROR")
def test_unchanged_schema_has_no_findings(tmp_path):
assert run(tmp_path, "message M { string a = 1; }", "message M { string a = 1; }") == []
def test_adding_a_field_is_only_a_note(tmp_path):
found = run(tmp_path, "message M { string a = 1; }", "message M { string a = 1; string b = 2; }")
assert errors(found) == []
assert [f.rule for f in found] == ["FIELD_ADDED"]
def test_removed_field_needs_reserved(tmp_path):
found = run(tmp_path, "message M { string a = 1; int32 b = 2; }", "message M { string a = 1; }")
assert errors(found) == ["FIELD_REMOVED_UNRESERVED"]
def test_removed_and_reserved_is_fine(tmp_path):
found = run(tmp_path, "message M { string a = 1; int32 b = 2; }",
'message M { reserved 2; reserved "b"; string a = 1; }')
assert errors(found) == []
assert [f.rule for f in found] == ["FIELD_REMOVED_RESERVED"]
def test_renumbering_is_a_removal(tmp_path):
found = run(tmp_path, "message M { string a = 1; }", "message M { string a = 5; }")
assert errors(found) == ["FIELD_REMOVED_UNRESERVED"]
assert "now has number 5" in found[0].text
def test_number_reuse_looks_like_a_rename(tmp_path):
found = run(tmp_path, "message M { int32 weight = 2; }", "message M { int32 count = 2; }")
assert errors(found) == ["FIELD_NAME_CHANGED"]
def test_a_rename_can_be_waived(tmp_path):
found = run(tmp_path, "message M { int32 weight = 2; }", "message M { int32 mass = 2; }",
waive=["FIELD_NAME_CHANGED:t.M.weight"])
assert errors(found) == []
assert [f.level for f in found] == ["WAIVED"]
def test_incompatible_type_change(tmp_path):
found = run(tmp_path, "message M { int32 a = 1; }", "message M { sint32 a = 1; }")
assert errors(found) == ["FIELD_TYPE_CHANGED"]
def test_compatible_type_change_still_needs_a_plan(tmp_path):
found = run(tmp_path, "message M { int32 a = 1; }", "message M { int64 a = 1; }")
assert errors(found) == ["FIELD_TYPE_COMPATIBLE_CHANGE"]
def test_singular_to_repeated(tmp_path):
found = run(tmp_path, "message M { int32 a = 1; }", "message M { repeated int32 a = 1; }")
assert errors(found) == ["FIELD_REPEATED_CHANGED"]
def test_moving_a_field_into_an_existing_oneof(tmp_path):
old = "message M { oneof p { string a = 1; string b = 2; } string c = 3; }"
new = "message M { oneof p { string a = 1; string b = 2; string c = 3; } }"
assert errors(run(tmp_path, old, new)) == ["FIELD_MOVED_INTO_ONEOF"]
def test_one_field_into_a_new_oneof_is_fine(tmp_path):
found = run(tmp_path, "message M { string a = 1; }", "message M { oneof p { string a = 1; } }")
assert errors(found) == []
def test_two_fields_into_a_new_oneof(tmp_path):
found = run(tmp_path, "message M { string a = 1; string b = 2; }",
"message M { oneof p { string a = 1; string b = 2; } }")
assert errors(found) == ["FIELD_MOVED_INTO_ONEOF"]
def test_enum_value_removed(tmp_path):
old = "enum E { E_UNSPECIFIED = 0; E_A = 1; E_B = 2; }"
assert errors(run(tmp_path, old, "enum E { E_UNSPECIFIED = 0; E_A = 1; }")) == ["ENUM_VALUE_REMOVED_UNRESERVED"]
found = run(tmp_path, old, "enum E { reserved 2; E_UNSPECIFIED = 0; E_A = 1; }")
assert errors(found) == []
def test_enum_value_rename(tmp_path):
old = "enum E { E_UNSPECIFIED = 0; E_A = 1; }"
assert errors(run(tmp_path, old, "enum E { E_UNSPECIFIED = 0; E_ONE = 1; }")) == ["ENUM_VALUE_NAME_CHANGED"]
def test_unreserving_is_an_error(tmp_path):
found = run(tmp_path, "message M { reserved 2; string a = 1; }", "message M { string a = 1; }")
assert errors(found) == ["RESERVED_REMOVED"]
def test_open_ended_reserved_range_is_cheap(tmp_path):
start = time.perf_counter()
found = run(tmp_path, "message M { reserved 100 to max; string a = 1; }",
"message M { reserved 100 to max; string a = 1; int32 b = 2; }")
assert errors(found) == []
assert time.perf_counter() - start < 5
python -m pytest -q test_protogate.py
................. [100%]
17 passed in 3.39s
Seventeen tests pass, one for every rule plus the safe cases, including the open-ended reservation. The time will differ on your machine; each test invokes the compiler twice.
Step 9: Replay old bytes through the new schema
The gate reads schemas. A second kind of check reads data: keep a few messages that an old version really wrote, and make every new schema parse them. This catches things a schema diff can miss, because it tests what the bytes mean rather than what the declarations say. Save golden.py.
"""golden.py: replay bytes written by the old schema through a new schema and report anything that changed meaning."""
from pathlib import Path
GOLDEN = Path(__file__).resolve().parent / "golden"
def write_samples(old):
"""Save two messages written by the old schema, one per payment variant."""
GOLDEN.mkdir(exist_ok=True)
common = dict(tracking_id="1Z-0042", weight_grams=2500, destination="Oslo", invoice_ref="INV-77",
status=old.Status.Value("SHIPMENT_STATUS_SHIPPED"))
(GOLDEN / "v1_card.bin").write_bytes(old.Shipment(card_last4="4242", **common).SerializeToString())
(GOLDEN / "v1_bank.bin").write_bytes(old.Shipment(bank_ref="NO93-0001", **common).SerializeToString())
def samples():
return [path.read_bytes() for path in sorted(GOLDEN.glob("*.bin"))]
def check(old, new, data):
"""old and new come from schemas.load; data was written by the old schema. Return a list of problems."""
problems = []
before = old.Shipment.FromString(data)
after = new.Shipment.FromString(data)
old_names = {f.name for f in old.Shipment.DESCRIPTOR.fields}
new_names = {f.name for f in new.Shipment.DESCRIPTOR.fields}
for field, _ in after.ListFields():
if field.name not in old_names:
problems.append(f"old bytes now fill '{field.name}', which did not exist when they were written")
for field, value in before.ListFields():
if field.name in new_names and getattr(after, field.name) != value:
problems.append(f"'{field.name}' was {value!r} and now reads {getattr(after, field.name)!r}")
if after.WhichOneof("payment") != before.WhichOneof("payment"):
problems.append(f"payment was {before.WhichOneof('payment')!r} and now reads {after.WhichOneof('payment')!r}")
return problems
write_samples saves two messages from the old schema, one per payment variant, because one message cannot hold both. check parses the bytes with both schemas and reports two kinds of problem: a field that did not exist when the bytes were written but is now filled, which is the signature of a reused number or a rename, and a field that still exists but now reads a different value, which is the signature of a type change. It also compares which oneof member is set. Save step09_golden.py and run it.
"""step09_golden.py: replay bytes written by schema v1 through each candidate schema."""
import golden
import schemas
old = schemas.load("v1")
golden.write_samples(old)
samples = golden.samples()
print(f"{len(samples)} golden files:", ", ".join(f"{len(data)} bytes" for data in samples))
for version in ("v2", "v3_reserved", "v3_reuse", "v3_rename", "v3_oneof", "v4_bad"):
new = schemas.load(version)
problems = []
for data in samples:
for problem in golden.check(old, new, data):
if problem not in problems:
problems.append(problem)
print(f"{version:<12} {'PASS' if not problems else 'FAIL'}")
for problem in problems:
print(" ", problem)
python step09_golden.py
2 golden files: 39 bytes, 34 bytes
v2 PASS
v3_reserved PASS
v3_reuse FAIL
old bytes now fill 'parcel_count', which did not exist when they were written
v3_rename FAIL
old bytes now fill 'delivery_address', which did not exist when they were written
v3_oneof FAIL
'bank_ref' was 'NO93-0001' and now reads ''
payment was 'bank_ref' and now reads 'invoice_ref'
'card_last4' was '4242' and now reads ''
payment was 'card_last4' and now reads 'invoice_ref'
v4_bad FAIL
old bytes now fill 'delivery_address', which did not exist when they were written
'tracking_id' was '1Z-0042' and now reads ''
'weight_grams' was 2500 and now reads 1250
'bank_ref' was 'NO93-0001' and now reads ''
payment was 'bank_ref' and now reads 'invoice_ref'
'card_last4' was '4242' and now reads ''
payment was 'card_last4' and now reads 'invoice_ref'
v2 and v3_reserved pass, since removing a field is not a problem for bytes that happen to contain it. v3_reuse and v3_rename fail with the same message, which is correct: the check sees an old field now landing in a new name. v3_oneof fails because the card number and the bank reference vanish. v4_bad fails with seven problems, including weight_grams reading 1250 instead of 2500, which is the ZigZag change from Step 5 showing up in real data.
Turn it into a test so it runs on every change. Save test_golden.py.
"""test_golden.py: old bytes must keep their meaning under each candidate schema."""
import pytest
import golden
import schemas
@pytest.fixture(scope="module", autouse=True)
def compiled():
for folder in schemas.PROTOS.iterdir():
if folder.is_dir():
schemas.compile_version(folder.name)
golden.write_samples(schemas.load("v1"))
@pytest.mark.parametrize("version, passes", [
("v2", True), ("v3_reserved", True), ("v3_reuse", False), ("v3_rename", False), ("v3_oneof", False), ("v4_bad", False),
])
def test_replay_old_bytes(version, passes):
old, new = schemas.load("v1"), schemas.load(version)
problems = [p for data in golden.samples() for p in golden.check(old, new, data)]
assert (not problems) is passes
python -m pytest -q test_golden.py
...... [100%]
6 passed in 0.74s
In a real project, commit the .bin files the day each schema version ships, and keep them for as long as any reader, log or queue might hold data that old.
Step 10: Run the gate in CI against the main branch
In a pull request, “old” is the schema on your main branch and “new” is the file in the working tree. The --git option compiles the file as it was at a revision, so a CI job needs only one command. This script builds a real throwaway repository, commits v2 on main, and runs the gate against two different working trees. Save it as step10_ci.py.
"""step10_ci.py: run the gate the way a CI job would, against the main branch of a real git repository."""
import subprocess
import sys
import tempfile
from pathlib import Path
LAB = Path(__file__).resolve().parent
GATE = [sys.executable, str(LAB / "protogate.py"), "--git", "main", "proto/shipment.proto"]
GIT = ["git", "-c", "user.name=ci", "-c", "[email protected]"]
def run(repo, *cmd):
return subprocess.run(cmd, cwd=repo, capture_output=True, text=True)
def show(repo, label, *cmd):
result = run(repo, *cmd)
print("$", label)
print((result.stdout + result.stderr).rstrip())
print("exit code", result.returncode, "\n")
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
repo = Path(tmp)
target = repo / "proto" / "shipment.proto"
target.parent.mkdir()
target.write_text((LAB / "protos" / "v2" / "shipment.proto").read_text())
run(repo, "git", "init", "-q", "-b", "main")
run(repo, "git", "add", "proto/shipment.proto")
run(repo, *GIT, "commit", "-qm", "baseline")
print("main holds the v2 schema\n")
for version in ("v3_reuse", "v3_reserved"):
target.write_text((LAB / "protos" / version / "shipment.proto").read_text())
show(repo, f"python protogate.py --git main proto/shipment.proto # working tree: {version}", *GATE)
show(repo, "python protogate.py --git origin/main proto/shipment.proto # origin/main was never fetched",
sys.executable, str(LAB / "protogate.py"), "--git", "origin/main", "proto/shipment.proto")
python step10_ci.py
main holds the v2 schema
$ python protogate.py --git main proto/shipment.proto # working tree: v3_reuse
ERROR FIELD_NAME_CHANGED shipping.Shipment.weight_grams: field 2 was 'weight_grams' and is now 'parcel_count'; a rename and a number reuse look the same in a diff
1 error(s), 0 waived, 0 note(s)
exit code 1
$ python protogate.py --git main proto/shipment.proto # working tree: v3_reserved
NOTE FIELD_REMOVED_RESERVED shipping.Shipment.weight_grams: field 2 removed and its number is reserved
NOTE FIELD_ADDED shipping.Shipment.parcel_count: new field 11 (int32); old readers keep it as an unknown field
0 error(s), 0 waived, 2 note(s)
exit code 0
$ python protogate.py --git origin/main proto/shipment.proto # origin/main was never fetched
git revision origin/main not found; fetch it before running the gate
exit code 1
The reuse is blocked with exit code 1, and the version that reserves the number passes with exit code 0. The third run is the safety net: when the base revision cannot be found, the gate fails instead of passing. In your CI configuration, fetch the base branch before the step (for example git fetch origin main), because a CI job may check out only a shallow copy of the pull request. The job itself is one line:
python protogate.py --git origin/main proto/shipment.proto
Step 11: Cross-check with buf breaking
The gate you built is small and covers one file. Buf’s buf breaking is an established tool that does the same job for whole projects. Running both on the same schemas shows where each stands. Download the Windows binary (buf-Windows-x86_64.exe) from the buf releases page into tools/buf.exe, or point the script at the buf you already have, and save step11_buf.py.
"""step11_buf.py: ask buf breaking (an established tool) about the same schema pairs, one rule category at a time."""
import json
import shutil
import subprocess
from pathlib import Path
LAB = Path(__file__).resolve().parent
BUF = LAB / "tools" / "buf.exe"
ROOT = LAB / "build" / "buf"
CATEGORIES = ["FILE", "PACKAGE", "WIRE_JSON", "WIRE"]
def stage(version):
"""Copy protos/<version> into its own folder with a minimal buf.yaml."""
folder = ROOT / version
folder.mkdir(parents=True, exist_ok=True)
shutil.copy(LAB / "protos" / version / "shipment.proto", folder / "shipment.proto")
(folder / "buf.yaml").write_text("version: v2\n")
return folder
def breaking(new, old, category):
config = json.dumps({"version": "v2", "breaking": {"use": [category]}})
cmd = [str(BUF), "breaking", str(new), "--against", str(old), "--config", config, "--error-format", "json"]
result = subprocess.run(cmd, capture_output=True, text=True)
found = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
return result.returncode, sorted({item["type"] for item in found}), result.stderr.strip()
print(subprocess.run([str(BUF), "--version"], capture_output=True, text=True).stdout.strip(), "(buf)")
old = stage("v2")
for version in ("v3_reserved", "v3_reuse", "v3_rename", "v3_oneof", "v4_bad"):
new = stage(version)
print(f"v2 -> {version}")
for category in CATEGORIES:
code, types, err = breaking(new, old, category)
print(f" {category:<10} exit {code:<3} {', '.join(types) if types else '(no findings)'}{(' ' + err) if err else ''}")
python step11_buf.py
1.73.0 (buf)
v2 -> v3_reserved
FILE exit 100 FIELD_NO_DELETE
PACKAGE exit 100 FIELD_NO_DELETE
WIRE_JSON exit 0 (no findings)
WIRE exit 0 (no findings)
v2 -> v3_reuse
FILE exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
PACKAGE exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
WIRE_JSON exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
WIRE exit 0 (no findings)
v2 -> v3_rename
FILE exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
PACKAGE exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
WIRE_JSON exit 100 FIELD_SAME_JSON_NAME, FIELD_SAME_NAME
WIRE exit 0 (no findings)
v2 -> v3_oneof
FILE exit 100 FIELD_SAME_CARDINALITY, FIELD_SAME_ONEOF
PACKAGE exit 100 FIELD_SAME_CARDINALITY, FIELD_SAME_ONEOF
WIRE_JSON exit 100 FIELD_SAME_ONEOF
WIRE exit 100 FIELD_SAME_ONEOF
v2 -> v4_bad
FILE exit 100 ENUM_VALUE_NO_DELETE, ENUM_VALUE_SAME_NAME, FIELD_NO_DELETE, FIELD_SAME_CARDINALITY, FIELD_SAME_JSON_NAME, FIELD_SAME_NAME, FIELD_SAME_ONEOF, FIELD_SAME_TYPE
PACKAGE exit 100 ENUM_VALUE_NO_DELETE, ENUM_VALUE_SAME_NAME, FIELD_NO_DELETE, FIELD_SAME_CARDINALITY, FIELD_SAME_JSON_NAME, FIELD_SAME_NAME, FIELD_SAME_ONEOF, FIELD_SAME_TYPE
WIRE_JSON exit 100 ENUM_VALUE_NO_DELETE_UNLESS_NAME_RESERVED, ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVED, ENUM_VALUE_SAME_NAME, FIELD_NO_DELETE_UNLESS_NAME_RESERVED, FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED, FIELD_SAME_JSON_NAME, FIELD_SAME_NAME, FIELD_SAME_ONEOF, FIELD_WIRE_JSON_COMPATIBLE_CARDINALITY, FIELD_WIRE_JSON_COMPATIBLE_TYPE
WIRE exit 100 ENUM_VALUE_NO_DELETE_UNLESS_NUMBER_RESERVED, FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED, FIELD_SAME_ONEOF, FIELD_WIRE_COMPATIBLE_CARDINALITY, FIELD_WIRE_COMPATIBLE_TYPE
Buf’s rules and categories page describes four built-in categories as “a strictness ladder”: FILE, PACKAGE, WIRE_JSON and WIRE. Three results stand out.
- The reuse passes the
WIREcategory. Forv3_reuseandv3_rename,WIREfound nothing andWIRE_JSONreportedFIELD_SAME_NAMEandFIELD_SAME_JSON_NAME. Buf describesFIELD_SAME_NAMEas checking “that the field name for a given field number doesn’t change”, and lists it inFILE,PACKAGEandWIRE_JSONbut notWIRE. That is the same observation behind the gate’s decision to block renames. Buf describesWIRE_JSONas the category that “Detects changes that break wire (binary) or JSON encoding” and says “this is the recommended minimum level.” - The five wire-level mistakes in
v4_badmatch. TheWIREcategory reported five rules: an unreserved field number, an unreserved enum number, a oneof change, a cardinality change and an incompatible type. Those are the five non-rename errors from Step 8. The two rename errors appear only fromWIRE_JSONupward.WIRE_JSONalso wants removed field and enum names reserved, which the gate does not check. - The oneof move is caught at every level.
FIELD_SAME_ONEOFbelongs to all four categories. Buf says it “checks that no field moves into or out of a oneof or changes the oneof it’s a part of.”
The FILE and PACKAGE categories also flagged v3_reserved, which the gate passes: removing a field breaks generated source code even when the wire is safe. Buf’s page says FILE “is absolutely necessary for C++ and Python.” If other teams compile your .proto files into their own code, use buf with at least WIRE_JSON, and FILE when generated source matters. The gate in this tutorial is a good way to understand the rules and a reasonable start for a single file.
Check that the whole thing works
Run every script and both test files from a clean shell. You should see exactly these results:
python step00_compile.pycompiles all seven schemas (v1,v2,v3_oneof,v3_rename,v3_reserved,v3_reuseandv4_bad) and printslibprotoc 35.1.python step02_unknown.pyshowscarrierandinsured_centskept by two forwarding styles and lost by two.python step04_reuse.pyshows a parcel count of 2500 and a weight of 3, then two compiler errors.python step08_gate.pyexits cleanly and prints exit codes 0, 0, 1, 1, 0 and 1 in that order.python -m pytest -qreports 23 passed (17 gate tests and 6 replay tests).
If a number differs, check versions first: a different protobuf or protoc release can word an error message differently, and the timings will always differ.
The cheat sheet
| Change | Verdict | What actually happens | Gate rule |
|---|---|---|---|
| Add a field with a new number | safe | old readers keep it as unknown; new readers see a default from old writers | note |
| Add an enum value | wire-safe | old readers see the bare number; code needs a default branch | note |
| Add a member to an existing oneof | wire-safe | old readers see no member set | note |
| Remove a field and reserve its number and name | safe | old bytes land in unknown fields | note |
| Remove a field without reserving | unsafe later | the number can be reused | error |
| Reuse a number | unsafe | silent corruption in both directions | error |
| Rename a field or an enum value | safe in binary, breaks JSON | JSON parsers reject or drop the data | error, waivable |
| Widen or narrow an integer type | compatible, lossy | high bits are cut, signs can flip | error, waivable |
Switch between int32 and sint32 |
unsafe | 5 reads as -3 | error |
| Repeated to scalar | unsafe | numbers vanish; strings keep the last value | error |
| Move a field into an existing oneof | unsafe | values set together lose all but one | error |
| Delete a reservation | unsafe | the number can be reused again | error |
What no tool can see
Every check in this tutorial compares shapes. None can tell that weight_grams started holding ounces, or that status = 2 now means a different state, because the bytes and the schema are identical before and after. Changes in meaning need a new field, a new number and a rollout plan, and a code review that looks at what a field means, not only at how it is declared.
The gate also has limits you should know about. It compiles one file at a time and does not follow imports. It does not check services or RPC methods, does not require that removed names be reserved, and does not know about extend ranges or editions features. Buf covers all of those. I did not test other languages, closed enums, or proto2 behaviour.
Next steps
- Add a rule that requires reserved names too, as
WIRE_JSONdoes, and write its test first. - Make the replay step part of release: commit golden files for every shipped schema and replay all of them, not just the first version.
- The replay in Step 9 is a small golden-master test, an idea this site covers at larger scale in How to Safely Refactor Legacy Python Code Using Characterization Tests and How to Use Differential Testing to Safely Replace Legacy Code in Python.
- Audit your forwarding services for field-by-field copies and JSON round trips, since Step 2 showed that these are the quiet way to lose new fields.
- Read the language guide’s section on updating a message type next to the best-practices page, and compare each rule with what you measured here.
Sources and method
The rules about adding, removing and changing fields, unknown fields and oneofs come from the protobuf Language Guide (proto 3); the advice on reserving numbers, changing types, repeated fields and text formats comes from Proto Best Practices; the tag formula, wire types, ZigZag and packed fields come from the Encoding page; and the presence rules come from Application Note: Field Presence. The buf categories and rule descriptions are from Buf’s rules and categories page.
Every number printed above was produced by the scripts in this tutorial on Windows 11 with Python 3.13.14, protobuf 7.36.2 (the upb implementation), grpcio-tools 1.84.0 (libprotoc 35.1), pytest 9.1.1 and buf 1.73.0. Other releases may word errors differently, and other languages may behave differently from Python. The wire-level behaviour measured here follows the protobuf rules, so it is expected to hold elsewhere, but only Python was tested.








No Comment! Be the first one.