Data structures and storage engines
Schema evolution
How to change a record's shape without breaking the producers and consumers that use it. Backward and forward compatibility, safe Protobuf and Avro changes, and schema registry modes.
A schema will change. A field gets added, a name gets clearer, a type turns out to be too small. The hard part is that producers and consumers do not upgrade at the same moment. Old data stays in logs and old code keeps running. A schema change is safe only if old and new versions can still read each other's data.
Concept
Backward compatible: new code can read data written by old code. Forward compatible: old code can read data written by new code, at least by ignoring what it does not know.
The big idea
Compatibility is a property of each change, not of the format. The same change can be safe in one direction and unsafe in the other. Adding an optional field, for example, is backward compatible: the new reader sees the default when the field is missing. It is forward compatible only if old readers ignore unknown fields.
Walking through a change
A producer writes version 1 of an event: {user_id, email}. A consumer is upgraded to version 2, which adds phone with a default of null.
v1 record from old producer: {user_id: 42, email: "ada@…"}
v2 consumer reads it: phone is missing → use default null ✓ backward OK
v2 record from new producer: {user_id: 42, email: "ada@…", phone: "555-0100"}
v1 consumer reads it: phone is unknown → ignore it ✓ forward OKBoth directions work, because the new field is optional and old code skips unknown fields. The change would break if phone were required, since the old producer's records would fail the new reader.
| Change | Backward | Forward |
|---|---|---|
| Add an optional field with a default | OK | OK if old code ignores unknown fields |
| Remove a field | OK if readers do not require it | OK if writers still omit it |
| Rename a Protobuf field (number unchanged) | OK on the wire | OK on the wire |
| Reuse a Protobuf field number for a new meaning | Breaks | Breaks |
| Change a field's type in place | Usually breaks | Usually breaks |
Safe rules for Protobuf and Avro
For Protobuf:
- Add new fields with new numbers. Old readers skip unknown tags.
- Never reuse a number. When you delete a field, mark its number and name as
reserved. - Change a type by adding a new field and migrating, not by editing the old one.
message User {
reserved 4, 5;
reserved "legacy_score";
int64 user_id = 1;
string email = 2;
bool active = 3;
string phone = 6; // added later, so it gets a new number
}For Avro:
- Give every new field a default, so readers can fill it in.
- Add an alias when you rename a field, so the reader can match the old name.
Schema registry modes
A schema registry stores each schema under a subject, for example one per event type. It can refuse a new schema that breaks the chosen compatibility mode. The mode is a deploy-order decision.
- BACKWARD: the new schema can read old data. Upgrade consumers first.
- FORWARD: old schemas can read new data. Upgrade producers first.
- FULL: both. The strictest choice, and the safest when many teams publish independently.
Setting the mode in the registry turns a bad change into a failed registration, instead of a failed consumer at 3 a.m.
A versioning rule your team can follow
Pick one rule per surface and write it down.
- Events on a log or queue: one registry subject per event type. Enforce a compatibility mode in CI for every schema change.
- Internal RPC: add optional fields for small changes. Create a new versioned method for breaking ones.
- Public HTTP APIs: add JSON fields for small changes. Use a version in the URL or a header for breaking ones, and give clients a deprecation window.
Mixing approaches causes trouble. Renaming a JSON key in place sometimes, and bumping the version other times, leaves clients broken and confused. Choose the boring, consistent path. See API design for the HTTP side.
Common mistakes
- Adding a required field. Old producers cannot supply it, so new readers fail.
- Deleting a field without reserving it. A later field can reuse the number, and old data decodes as garbage.
- Changing a type in place. Add a new field and migrate instead.
- Deploying in the wrong order. Match the upgrade order to the registry mode.
Check yourself
- Why is adding an optional field safe in both directions? New readers fill in the default when the field is missing. Old readers skip the unknown field.
- You want consumers upgraded before producers. Which registry mode fits? BACKWARD, because the new consumers must read the old producer's data.
Key takeaways
- A change is safe when old and new versions can read each other's data.
- Optional fields with defaults are the safe default. Required fields and type changes are not.
- Never reuse Protobuf numbers. Reserve deleted ones, and add aliases for renamed Avro fields.
- Enforce a compatibility mode in the schema registry, and set the deploy order to match.
Further learning
Confluent's short introduction to Schema Registry explains backward, forward and full compatibility with examples.
Next, read compression tradeoffs for shrinking the bytes once the format is settled.
Related
Serialization, schemas and compression
How data becomes bytes for the wire and for disk, how formats like JSON, Protobuf and Avro differ, how to change a schema safely, and when compression pays. Start here for data formats.
Binary schemas, Protobuf and Avro
How Protobuf uses permanent field numbers and Avro uses writer and reader schemas to read compact binary data, and when to choose each.
Event streaming and Kafka
How a durable log lets many consumers read the same events at their own pace, and how Kafka builds that log from partitions, replicas and consumer groups.
API design
An API is a contract that outlives the code behind it. Design the styles, methods, pagination, errors and async flows so changes stay additive.