Protobuf breaking changes: what survives on the wire
6 min read
A REST API changes when a JSON shape changes, and most of that shape is visible in the response
you can read in a browser. A gRPC API changes when a .proto file changes, and Protocol Buffers’
binary wire format has its own rules about what a client can tolerate that have nothing to do with
what the field names say. Two edits that look equally small in a diff, renumbering a field versus
adding one, land on opposite sides of a line that breaking changes
draws in general: one is invisible to every existing client, the other breaks all of them at once.
Telling protobuf breaking changes apart from safe ones means reading the wire format’s own rules,
not guessing from how the change reads in a .proto diff.
Why does field numbering matter more than field naming in Protobuf?
Because the wire format encodes fields by number, not by name. The generated code in every
language reads and writes those numbers; the field name email in your .proto file is a
convenience for humans that never touches the binary bytes sent over the network. Renaming a
field, email to email_address, is safe on the binary wire as long as the number stays the
same, which surprises engineers used to REST, where a renamed JSON key is exactly the kind of
change that breaks a client. The exception is the same REST case: the
ProtoJSON and text formats serialize the name, so a
rename breaks JSON transcoding (a grpc-gateway, for example), text-format files and field masks. Renumbering that same field, keeping the name but changing 1 to
7, is exactly backwards: invisible in a code review that only shows names, and it corrupts every
message a client sends or receives from that point on.
| Change | Safe on the wire | Why |
|---|---|---|
| Rename a field, keep its number | Binary yes, JSON and text no | Binary encoding uses the number; ProtoJSON and text format use the name |
| Change a field’s number | No | Every existing message is now misread as the wrong field |
| Add a new field with a new number | Yes | Old clients ignore fields they don’t recognize |
| Remove a field, reuse its old number for something else | No | Old data decodes into the wrong new field |
Change a field’s type incompatibly (e.g. int32 to string) | No | The wire encoding differs per type |
What makes removing a field different from removing one in a REST JSON response?
The number becomes radioactive. Protobuf’s own guidance recommends marking a removed field’s
number reserved rather than letting it be reused, because reuse is where the real damage
happens: a client still running last month’s generated code sends a message using the old field’s
number for the old meaning, and the server, now expecting that number to mean something else,
silently misinterprets the data instead of rejecting it outright. REST has no equivalent trap,
because a removed JSON key just stops appearing; there’s no way for an old client’s request to be
quietly reinterpreted as something else. A .proto file with reserved 4, 9, 12; at the top of a
message is a permanent scar, and that’s the point: it stops the number from being handed to a new
field by someone who didn’t know its history.
message Invoice {
reserved 4; // was `legacy_customer_id`, removed 2026-06-01
reserved "legacy_customer_id"; // the name too, for JSON/text
string customer_id = 5;
string status = 6;
}
Does adding a field ever require a changelog entry at all?
Usually not a breaking-change entry, but often a plain one, because “safe on the wire” and “invisible to a reader who cares” are different claims. Adding a field to a response message costs nothing structurally, old clients decode the message and ignore the new field automatically. But a caller building a new integration against that service has no way to know the field exists unless someone tells them, since nothing about a successful build or a passing test surfaces a new optional field. API changelog covers what an additive entry owes readers in general; the gRPC-specific reason to still write one is that there’s no equivalent of browsing a REST response in a debugger to notice a new key showed up.
How is this different from what GraphQL callers deal with?
The rules for additions match, but the exposure differs. GraphQL schema deprecation covers a model where a client only receives fields it explicitly requests, which makes additive changes essentially risk-free and removals the only real danger. gRPC clients, by contrast, receive whatever the server sends and decode all of it against their own compiled copy of the schema; a client’s exposure isn’t bounded by what it asked for, only by what its generated code knows how to read. That difference matters for changelog writing: a GraphQL entry can reasonably assume clients are shielded from fields they didn’t request, and a gRPC entry can’t make that assumption at all.
Does versioning a gRPC service work the same way as REST’s /v1/, /v2/?
The mechanism is different even when the intent is the same. What is v1 and v2 in a REST
API covers versioning as parallel URL paths serving
different contracts; gRPC services typically version through the package name in the .proto
file itself, payments.v1.InvoiceService becoming payments.v2.InvoiceService, which changes the
fully-qualified service name a client dials rather than a URL segment it requests. Both approaches
solve the same problem, letting an old contract keep working while a new one exists, but a team
moving from a REST background often looks for a version number in the wrong place and misses that
the package declaration is doing that job.
What should a gRPC changelog entry actually name?
The message, the field number, and whether it’s additive or a removal requiring migration, in
that order of importance to a reader deciding whether to act. “Added shipping_address (field 8)
to Order” tells an integrator everything needed to update generated code and start using it.
“Reserved field 4 on Invoice, legacy_customer_id is gone” tells them to check whether anything
in their codebase still reads that field, which a REST-style “removed a field from the response”
note doesn’t communicate with the same urgency, because REST removals just return less data while
Protobuf field reuse actively corrupts it.
FAQ
Can a field’s type ever be changed without breaking the wire format?
Only within specific compatible groups Protobuf documents, like widening int32 to int64 in
some cases. Treat any type change as breaking unless you’ve checked it against Protobuf’s own
compatibility table; assuming compatibility by analogy with a language’s type system is how this
goes wrong.
Does deprecating a field in Protobuf work like GraphQL’s @deprecated directive?
Similarly: Protobuf supports a [deprecated = true] field option that tooling can surface. Neither
is enforced: a GraphQL server still answers a query for a deprecated field, and a protobuf client
still encodes one. Both are advisory and need the same changelog backup.
Is renumbering ever safe if you control every client? In a fully closed system, in principle, but it removes the entire safety property that field numbers exist to provide, and “we control every client” is a claim that stops being true the moment a build is cached, a deploy is delayed, or a client is added that nobody remembered. Reserve the number instead of reusing it, even internally.
Do gRPC services need a changelog page the way a public REST API does?
Only if external teams consume them without reading .proto diffs directly, the same
“who’s on the other end” test internal API changelogs applies
generally. A gRPC service consumed only by its own team’s other services can often skip a formal
changelog in favor of the commit history, since everyone reading it already has the schema open.
The technical claims in this article have not been independently reviewed. If something here is wrong, tell us and we will correct it.