A .proto file is a wire contract between services that deploy on different schedules, not a convenience input to code generation. The generated types will happily compile against a schema an old client has never seen — and that client will keep sending bytes shaped for the version it was built against. Whether that ends in correct data or silent corruption comes down to one thing the field names never touch: the field numbers.
protoc turns a .proto into two kinds of output. Message definitions become language-specific classes with serialize/parse methods; service definitions become client stubs and server base classes when you run the matching gRPC plugin (protoc-gen-go-grpc, @grpc/grpc-js, etc.). Nothing on the wire records a field's name. What identifies a value is its tag: (field_number << 3) | wire_type.
syntax = "proto3";
service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); }
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }
How a reused field number corrupts data with no error
The dangerous change is not deleting a field — it's letting the number come back with a different type. Say v1 had int32 user_id = 2 and someone deletes it, then a later edit adds string email = 2. Both compile. Here is what the old client's bytes do against the new server:
# v1 client encodes user_id = 42 into field 2.
# tag = (2 << 3) | 0 -> 0x10 (wire type 0 = varint)
10 2a # field 2, value 42
# v2 server schema: message User { string email = 2; }
# It expects field 2 as wire type 2 (length-delimited).
# The byte on the wire says wire type 0. Field number matches,
# wire type does not -> the value is treated as unknown and dropped.
email = "" # no exception, no log line, no metricThe takeaway: the parser trusts the tag byte, not your schema, so a type mismatch on a known field number is a data-loss bug that never throws. That's why reserved exists — retiring both the number and the name blocks the compiler from ever handing that slot to a future editor.
message User {
string display_name = 1;
reserved 2;
reserved "legacy_name";
string locale = 3;
}
One more practical detail hides in the tag encoding: field numbers 1–15 fit in a single tag byte, 16–2047 take two. Put your hot, high-frequency fields in the low numbers and leave the 16+ range for rarely-set ones — it's free bytes on every message.
FAQ
Do I need gRPC to use Protocol Buffers?
No. Protobuf is just the serialization format and schema language; you can encode/decode messages and store or ship them over any transport — Kafka, a file, plain HTTP. gRPC is the RPC framework that happens to use protobuf for its method signatures. If you only need the compact binary format, skip the service blocks entirely.
Is renaming a field safe?
On the wire, yes — the number is unchanged, so serialized bytes still parse. It is not free everywhere else: protobuf's canonical JSON mapping keys off the field name, so a rename breaks any JSON consumer, and it breaks generated API docs and every accessor in already-generated client code.
Can I widen an int32 to int64?
Yes. int32, int64, uint32, uint64, and bool all share wire type 0 (varint), so promoting a field's width is wire-compatible in both directions. Changing int32 to string, bytes, or a message is not — those are wire type 2, and you get the silent-drop failure above.
What happens if two fields claim the same number?
protoc rejects it at compile time with a duplicate field number error, so you can't ship that directly. The way it actually reaches production is deletion-then-reuse across two commits — which the compiler cannot see. reserved is the only durable guard.
proto3 has no required — how do I know a field was set?
Proto3 dropped required deliberately (it made schema evolution nearly impossible). For genuine presence tracking, mark the field optional, which restores a real "was it set?" bit. Otherwise an absent scalar is indistinguishable from its default (0, "", false).
Treat .proto review with the seriousness of a public HTTP API review: put a breaking-change linter (Buf, protoc with a baseline) in CI, because the compiler will tell you a schema is valid long before it tells you it's compatible.
Cover photo by Brett Sayles on Pexels.
