Schema evolution and the gotchas
This is the phase that earns the whole guide. Small and fast got your attention; schema evolution is what keeps a distributed system from melting down every time someone adds a field. In production, producers and consumers do not upgrade in lockstep. For minutes or days, new code and old code run side by side, reading each other's bytes. The schema's job is to make that overlap safe, and to tell you, before you ship, when a change is not.
Two words you have to keep straight
Everything here is one of two questions. Say them out loud when you review a change:
- Backward compatible: can new code read old data? You upgraded the reader first; it must still understand messages written by the old writer.
- Forward compatible: can old code read new data? You upgraded the writer first; the not-yet-upgraded reader must not choke on the new messages.
reads old data reads new data
new reader -> BACKWARD (n/a)
old reader -> (n/a) FORWARD
What just happened: the direction is named after the data, not the code. "Backward compatible" means the new thing reaches back to old data. Full compatibility means a change is both, which is what you usually want for an independently deployed system.
Protobuf: the rules live in the field numbers
Remember those field numbers from the .proto. They are the identity of each field, and the evolution rules fall right out of that.
message Customer {
int64 id = 1;
string name = 2;
bool is_active = 3;
int64 balance_cents = 4;
string email = 5; // newly added field
}
What just happened: you added email with a new, never-before-used number 5. An old reader that does not know field 5 sees those bytes, does not recognize the tag, and skips them, so old code reads new data fine (forward compatible). A new reader handed old data finds field 5 absent and uses the default (empty string), so new code reads old data fine (backward compatible). Adding an optional field with a fresh number is safe in both directions.
The rules that keep Protobuf safe, and the ones that wreck it:
- Never reuse or change a field number. The number is the identity. If you delete field 4 and later add a different field as number 4, old data's
balance_centsbytes get read as the new field, silent corruption. Usereserved 4;to fence off retired numbers so nobody reuses them. - Renaming a field is free on the wire.
nametofull_namekeeps number 2, so the bytes are identical. Only your source code changes. - Do not change a field's type incompatibly. Swapping
int64tostringon the same number reinterprets the bytes and breaks readers. Some numeric widenings are compatible; arbitrary type swaps are not.
message Customer {
reserved 4; // balance_cents retired; never reuse this number
reserved "balance_cents";
int64 id = 1;
string name = 2;
}
What just happened: reserved tells the compiler to reject any future field that tries to claim number 4 or the old name. It is a tombstone, and it is the difference between a safe deletion and a time bomb.
Avro: the rules live in defaults and the reader/writer schema pair
Avro has no field numbers. It matches fields by name, and it does something Protobuf does not: at decode time it uses two schemas, the writer's schema (what made the data) and the reader's schema (what your code wants). Avro resolves the difference between them. That pairing is the core of Avro evolution.
- To add a field, give it a
default. When old data (writer's schema lacks the field) is read by new code (reader's schema has it), Avro fills in the default. No default, and reading old data fails. - To remove a field, the field you drop must have had a default so that new data (missing it) can still be read by old code expecting it.
- Renaming uses
aliases. Because Avro matches on name, a raw rename looks like "old field gone, new field added." Analiastells the reader the new name also answers to the old one.
What just happened: email carries "default": "". A reader on this schema decoding older data that has no email substitutes the default instead of failing. That single default is what makes the add backward compatible. In Avro, defaults are not a convenience, they are the evolution mechanism.
The Schema Registry enforces this for you
You do not have to remember all of that by hand under deadline. A Schema Registry (the Kafka pattern from Phase 2) checks every new schema version against a configured compatibility mode before it accepts it.
BACKWARD (the common default): new schema can read data written by the previous schema
FORWARD: previous schema can read data written by the new schema
FULL: both directions
NONE: no checks (you are on your own)
What just happened: register an incompatible schema under BACKWARD mode and the registry rejects it at registration, before a single bad message is produced. The compatibility rule becomes a gate in your pipeline, not a postmortem. Set the mode to match how you deploy: upgrade consumers first, lean BACKWARD; upgrade producers first, lean FORWARD; want freedom in either order, use FULL.
Gotchas that bite real teams
Reusing a Protobuf field number is the classic disaster. It does not error. Old bytes get reinterpreted as the new field and you ship corrupted reads that pass every type check. Always
reserveddeleted numbers, and treat the number space as append-only forever.
A few more that show up in incident reviews:
- proto3 and the meaning of "missing." In plain proto3 scalar fields, a field set to its default (
0,"",false) is indistinguishable on the wire from a field never set. If "absent" must differ from "zero," model it explicitly (for example with anoptionalfield or a wrapper) rather than assuming you can tell them apart. - Required fields are a trap. proto2 had
required, and it made evolution nearly impossible, you can never safely remove a required field. proto3 dropped it on purpose. Resist any urge to simulate hard-required fields at the schema layer; enforce required-ness in application logic. - Compatibility is transitive, or it should be. Checking each new version only against the immediately previous one lets data drift across many hops. If you keep long histories of data around, use the registry's transitive modes so a new schema is checked against all prior versions, not only the last.
- The schema and the data must not drift apart. Lose the
.protothat compiled a producer, or fail to register an Avro schema, and you have opaque bytes nobody can decode. Version schemas in source control; treat the registry as production infrastructure, with backups.
In the wild
The teams who stay calm during rollouts are the ones who made compatibility a build-time gate: schemas in source control, a registry in FULL or BACKWARD mode, CI that fails the pipeline when a .proto or Avro change would break a live consumer. The format is not what saves you, the discipline of checking evolution before you ship is. Protobuf and Avro give you a place to enforce it.
[
{
"q": "A change is described as 'backward compatible.' What does that guarantee?",
"choices": ["Old code can read new data", "New code can read old data", "The wire format shrinks", "Field names are preserved"],
"answer": 1,
"explain": "Backward compatibility means the new reader reaches back to data written by the old writer: new code reads old data."
},
{
"q": "In Protobuf, why is reusing a deleted field number so dangerous?",
"choices": ["It throws a compile error every time", "Old data's bytes for that number get silently reinterpreted as the new field, corrupting reads with no error", "It doubles the message size", "It deletes the registry"],
"answer": 1,
"explain": "The number is the field's identity on the wire. Reusing it makes old bytes decode as the new field silently. Mark deleted numbers `reserved`."
},
{
"q": "In Avro, what makes adding a new field backward compatible so readers can decode older data that lacks it?",
"choices": ["Assigning it a field number", "Giving the field a `default` value", "Marking it `required`", "Putting it first in the record"],
"answer": 1,
"explain": "Avro matches by name and resolves reader vs writer schemas; a `default` lets the reader fill in the value when older data omits the field."
}
]
← Phase 2: Using them day to day | Overview
Check your understanding 3 questions
1. A change is described as 'backward compatible.' What does that guarantee?
2. In Protobuf, why is reusing a deleted field number so dangerous?
3. In Avro, what makes adding a new field backward compatible so readers can decode older data that lacks it?