TRENDING
A metal tap in silhouette beside a window with drops of water falling from its spout
October 12, 2026
How to Find and Fix a Memory Leak in a Python Service With tracemalloc
A split-flap arrivals board at Łódź Kaliska railway station in Poland listing trains by time and platform, a picture of a fixed timetable
October 12, 2026
Ai2’s GPU Scheduler Turns Priority Into a Budget, but Leaves the Preemption Bill Unreported
Close-up of an industrial machine operator control panel with a red emergency stop button, push buttons, a handwheel and a display screen
October 12, 2026
Booz Allen Says Frontier AI Met All Eight Attack Goals in Its OT Lab, With a Human Approving Every Exploit
A brushed-metal hotel key fob stamped with the number 21 on a ring with a brass key, lying on grey-green carpet
October 12, 2026
How to Evolve a Protobuf Schema Without Breaking Old Clients and Block Unsafe Edits in CI With Python
Red emergency brake handle on a brushed steel plate inside a train, with a label reading Hätäjarru, Nödbroms and Emergency brake
October 12, 2026
Nadella’s Insider-Risk Essay Turns AI Containment Into an Access-Control Test From the 1970s
12 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two climbers in helmets and backpacks walk up a snow slope, joined by a taut rope between them
How to Replace asyncio.gather With TaskGroup in Python and Handle Failures With except*
October 12, 2026
A gray parking meter with a red EXPIRED flag in its window and a dark parked car blurred behind it
Let’s Encrypt’s 64-Day Default Turns Hard-Coded Renewal Numbers Into a Deadline
October 12, 2026
Aerial view of a worn dirt desire line cutting straight across a hillside beside a curving paved trail
Attackers Probed a CVSS 10 SonicWall SMA1000 Flaw Two Days After Its Patch, the Third Such Fix in 12 Weeks
October 12, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 238 Posts
News 241 Posts
Learning Hub 211 Posts
Home/Learning Hub/How to Evolve a Protobuf Schema Without Breaking Old Clients and Block Unsafe Edits in CI With Python
Learning Hub

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.

October 11, 2026 45 Min Read
12

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 upb implementation.
  • 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 protoc install. The grpcio-tools package ships a compiler, and you run it as python -m grpc_tools.protoc.
  • Optional for Step 11: the single-file buf binary 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 WIRE category. For v3_reuse and v3_rename, WIRE found nothing and WIRE_JSON reported FIELD_SAME_NAME and FIELD_SAME_JSON_NAME. Buf describes FIELD_SAME_NAME as checking “that the field name for a given field number doesn’t change”, and lists it in FILE, PACKAGE and WIRE_JSON but not WIRE. That is the same observation behind the gate’s decision to block renames. Buf describes WIRE_JSON as 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_bad match. The WIRE category 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 from WIRE_JSON upward. WIRE_JSON also wants removed field and enum names reserved, which the gate does not check.
  • The oneof move is caught at every level. FIELD_SAME_ONEOF belongs 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:

  1. python step00_compile.py compiles all seven schemas (v1, v2, v3_oneof, v3_rename, v3_reserved, v3_reuse and v4_bad) and prints libprotoc 35.1.
  2. python step02_unknown.py shows carrier and insured_cents kept by two forwarding styles and lost by two.
  3. python step04_reuse.py shows a parcel count of 2500 and a weight of 3, then two compiler errors.
  4. python step08_gate.py exits cleanly and prints exit codes 0, 0, 1, 1, 0 and 1 in that order.
  5. python -m pytest -q reports 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_JSON does, 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.

Tags:

API DesignBackward CompatibilityProtocol BuffersPythonSoftware Testing

Share

Red emergency brake handle on a brushed steel plate inside a train, with a label reading Hätäjarru, Nödbroms and Emergency brake
Previous Post

Nadella’s Insider-Risk Essay Turns AI Containment Into an Access-Control Test From the 1970s

Close-up of an industrial machine operator control panel with a red emergency stop button, push buttons, a handwheel and a display screen
Next Post

Booz Allen Says Frontier AI Met All Eight Attack Goals in Its OT Lab, With a Human Approving Every Exploit

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
12 Oct
How to Replace asyncio.gather With TaskGroup in Python and Handle Failures With except*
12 Oct
Let’s Encrypt’s 64-Day Default Turns Hard-Coded Renewal Numbers Into a Deadline
Trending
October 12, 2026
How to Replace asyncio.gather With TaskGroup in Python and Handle Failures With except*
October 12, 2026
Let’s Encrypt’s 64-Day Default Turns Hard-Coded Renewal Numbers Into a Deadline
October 12, 2026
Attackers Probed a CVSS 10 SonicWall SMA1000 Flaw Two Days After Its Patch, the Third Such Fix in 12 Weeks
October 11, 2026
How to Find and Fix a Memory Leak in a Python Service With tracemalloc
October 11, 2026
Ai2’s GPU Scheduler Turns Priority Into a Budget, but Leaves the Preemption Bill Unreported
October 11, 2026
Booz Allen Says Frontier AI Met All Eight Attack Goals in Its OT Lab, With a Human Approving Every Exploit

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026