Engineering Practice
In a Wire Format, the Field Number Is the Contract
In a binary format like Protocol Buffers the field name is a comment and the field number is the real contract — so evolving a schema safely means knowing what the wire actually keys on, because renaming is free and renumbering is a data-corrupting break.
- Engineering Practice
- APIs
- Serialization
- Architecture
The first time you work with a binary serialization format like Protocol Buffers, it looks
a lot like a struct definition with some funny = 1, = 2 annotations after each field.
It’s tempting to treat those numbers as decoration and the field names as the important
part, the way they’d be in JSON. That instinct is exactly backwards, and it’s the source of
a whole class of nasty, silent data-corruption bugs. On the wire, the number is the
contract. The name is a comment.
The wire doesn’t carry your field names
When a binary format like protobuf encodes a message, it doesn’t write “customerId” into the bytes. It writes the field number, a type tag, and the value. The name exists only in the schema file that humans and code generators read; it never travels. So the decoder on the other end doesn’t look for a field called “customerId” — it looks for field number 3 and trusts that number to mean what it meant when the data was written.
The name is for you. The number is for the machine. Confuse which one is load-bearing and you’ll ship a change that reads clean and corrupts data on the wire.
This is the mental model that makes every schema-evolution rule obvious instead of memorized. Once you internalize “the number is the identity,” the do’s and don’ts stop being arbitrary.
Renaming is free; renumbering is a break
Because the name never hits the wire, renaming a field is safe — you rename it in the schema, regenerate, and every existing encoded byte still decodes fine, because the number didn’t move. Do it whenever a better name would help the humans.
Renumbering a field, or reusing a retired number, is a data-corrupting break. If field 3 used to be a customer ID and you renumber it or hand its number to a new field, old data that wrote “customer ID” under number 3 will now be read as whatever number 3 means today. Nothing errors. The bytes are valid. They just mean something different than they did, and you find out when the wrong value shows up somewhere downstream. This is the failure that teaches people the number is the contract, usually the hard way.
The rules of safe evolution fall right out
Hold “the number is the identity” in your head and the whole discipline is derivable:
- Add new fields with new numbers. A reader that doesn’t know the new number simply skips it — forward compatibility for free. Old and new coexist because they agree on the numbers they share.
- Never change the type under an existing number. The number promises a wire type; swapping it under the same number reinterprets old bytes.
- Never reuse a number. When you remove a field, its number is radioactive — retire it and mark it reserved so no one can innocently hand it to a future field. Good formats let you reserve both the number and the old name so a mistake fails at compile time instead of on the wire.
- Don’t rely on field order. The number identifies the field; position doesn’t. That’s the whole reason these formats survive reordering that a positional format wouldn’t.
Every one of those is just “protect the number, ignore the name” restated.
Know what your format actually keys on
The deeper, portable lesson isn’t about protobuf specifically. It’s that every serialization format has something it treats as the real identity of a field, and safe evolution means knowing what that something is:
- Binary tag formats (protobuf and friends) key on the number. Names are free to change; numbers are sacred.
- JSON and most text formats key on the name. Now renaming is the dangerous move and there are no numbers to worry about.
- Positional formats (CSV, fixed-width, some binary structs) key on order. Inserting a column in the middle shifts everything after it, so append-only is the rule.
Pick the wrong mental model for the format in front of you and you’ll follow the wrong evolution rules — protect the names when the wire cares about numbers, or freely reorder when position is load-bearing. This is the same idea as error codes being an API contract: the part consumers actually bind to is the part you’re not allowed to change casually, and it’s not always the part that looks most important to a human. And it’s why the schema is the real documentation — the schema is where the contract is written down, if you know how to read what it’s really promising.
So before evolving any serialized format, I ask one question: what does the wire key on here — name, number, or position? The answer tells me which changes are free and which will quietly corrupt data. In a binary tag format, the answer is the number, every time, and the name is just there to keep me from getting confused. If you’ve debugged a schema-evolution bug that came down to a reused tag, I’d like to hear about it.