On the wire a field is only its number, so the number is the contract; names exist for code and JSON. Two trimmed copies of Order stand in for later versions, one evolved safely and one broken:
syntax = "proto3";
package booknest.v1;
// Trimmed copies of Order: message names never travel on the wire.
message OrderV2 { // a newer producer: currency dropped, gift_wrap added
reserved 6;
reserved "currency";
int64 order_id = 1;
int64 total_cents = 10;
bool gift_wrap = 11;
}
message OrderV3 { // a broken change: field 10 reused with another type
int64 order_id = 1;
double total = 10;
}from google.protobuf.unknown_fields import UnknownFieldSet
import evolution_pb2 as ev
import order_pb2 as v1
data = ev.OrderV2(order_id=1, total_cents=4020, gift_wrap=True).SerializeToString()
old = v1.Order.FromString(data) # an old consumer reads new data
unknown = [(f.field_number, f.data) for f in UnknownFieldSet(old)]
print("v1 reads v2:", old.order_id, old.total_cents, repr(old.currency), "unknown:", unknown)
again = ev.OrderV2.FromString(old.SerializeToString()) # old consumer re-publishes it
print("gift_wrap after the v1 round trip:", again.gift_wrap)
broken = ev.OrderV3.FromString(v1.Order(order_id=1, total_cents=4020).SerializeToString())
print("v3 reads v1: total =", broken.total, "unknown:",
[(f.field_number, f.wire_type, f.data) for f in UnknownFieldSet(broken)])v1 reads v2: 1 4020 '' unknown: [(11, 1)] gift_wrap after the v1 round trip: True v3 reads v1: total = 0.0 unknown: [(10, 0, 4020)]
The old reader keeps field 11 as an unknown field, so gift_wrap survives a round trip through code that has never heard of it, and the dropped currency reads as an empty string. The broken version is worse than an error: field 10 arrives as a varint where double expects I64, so it lands among the unknown fields and total silently reads 0.0. So never change or reuse a field number or name (list retired ones under reserved), add fields freely, and change types only within a group sharing an encoding, such as int32, int64, uint64 and bool. Enforce this in CI: buf breaking from the buf CLI (Apache 2.0, github.com/bufbuild/buf (https://github.com/bufbuild/buf 11,472 )) fails a build on a reused number or an incompatible type, and a schema registry (Apache Kafka and Managed Cloud Kafka) applies the same checks to Kafka 129 topics.