Schema Evolution & Wire Compatibility

Backward, forward and full compatibility stated precisely, the concrete rules for changing a Protobuf, Avro or JSON contract without breaking a deployed peer, and expand–contract as the general recipe for changing anything two versions must agree on.

The moment a message format is written by one process and read by another that deploys separately, both versions exist simultaneously — during every rollout, in every replay of an old log, and for as long as any client refuses to upgrade. Schema evolution is the discipline of making changes that survive that overlap. The vocabulary is worth getting exactly right, because "backward compatible" is used by different teams to mean opposite things.

The direction determines the rollout order, which is the only reason to care:

text
backward only:  deploy all readers ──► then writers
forward only:   deploy all writers ──► then readers
full:           any order, any overlap        ← the goal

What each change does #

Change Backward Forward
Add an optional field with a default ✅ ✅ (old reader ignores it)
Add a required field ❌ old data has no value ❌ old reader may reject
Remove an optional field ✅ ✅ if the reader tolerates absence
Remove a required field ❌ ❌
Rename a field depends on the format — see below
Widen a type (int32→int64) ✅ in protobuf varints ❌ old reader may truncate
Narrow a type ❌ ❌
Add an enum value ✅ ❌ unless the reader has an unknown-value path
Reuse a removed field number/tag ❌❌ silently misinterprets old data ❌❌

Protocol Buffers #

The design decision that makes protobuf evolvable: field numbers, not names, are on the wire. Renaming a field is therefore free at the binary level — and not free if anything maps the message to JSON, where the name is the key.

proto
message Order {
  reserved 4, 7 to 9;              // numbers of deleted fields — never reuse
  reserved "legacy_status";        // and their names, for JSON mappings
  string id       = 1;
  int64  amount   = 2;
  Currency currency = 3;
  optional string note = 10;       // explicit presence: distinguishes unset from ""
}

Rules that actually matter:

  • Never reuse a field number. An old producer still sending tag 4 as a string will be decoded by a new consumer as whatever type now owns tag 4. Protobuf's wire format does not carry enough type information to catch this — you get garbage, not an error. reserved makes the compiler enforce it.
  • Field numbers 1–15 use one byte, 16–2047 use two. Spend the low numbers on frequent fields.
  • proto3 removed required on purpose. In proto2, a required field can never be removed and can never be added — it is permanently un-evolvable, and a peer that omits it makes the message undecodable. The lesson generalizes past protobuf: required is forever.
  • Presence. proto3 originally could not distinguish "absent" from "zero/empty string", which makes partial-update semantics impossible. optional (re-introduced) and wrapper types restore explicit presence. Decide deliberately: a 0 that means "not set" is a bug generator.
  • Unknown fields are preserved by default in proto3 implementations, so a proxy that decodes and re-encodes does not silently strip fields it does not know. This is what makes forward compatibility real rather than theoretical.

Avro #

Avro takes the opposite approach: the writer's schema and the reader's schema are both present at decode time, and resolution is a defined algorithm rather than a convention. That is why it dominates long-lived event logs, where data written years ago must still be readable.

The consequences are specific: adding a field requires a default (so the reader can materialize it for old records), and removing a field requires that the old schema had a default (so an old reader can cope). Fields are matched by name, not position, and a rename needs an aliases entry. A schema registry enforces the compatibility level per subject, which turns "we broke the consumers" into a rejected publish.

JSON and HTTP APIs #

There is no format-level help, so the rules are social and must be written down:

  • The tolerant reader: ignore unknown fields, never fail on extra data. This is the single convention that makes additive change possible — and note it is in tension with rejecting unknown input at a trust boundary (boundaries). Resolve it by direction: be strict about data you own end-to-end, tolerant about messages from peers you cannot redeploy.
  • Additive only: new fields are optional; existing fields never change type or meaning. Changing the meaning of a field while keeping its type is the worst case, because nothing detects it.
  • Never repurpose a name. Same reason as protobuf tag reuse, with no compiler to help.
  • Enums must have an unknown branch on the reading side from day one, or the first new value takes down every old client.

Expand–contract, the general recipe #

Any change that two versions must agree on becomes safe by splitting it into additive steps, with a period where both forms are supported. Fowler's parallel change:

text
1. EXPAND    add the new field/column/endpoint alongside the old; nothing reads it yet
2. MIGRATE   write both;  backfill existing data;  then switch readers to the new one
3. CONTRACT  stop writing the old;  wait out the retention/rollback window;  remove it

The same three steps cover a database column (schema migrations), an RPC field, a queue message, a cache key, and a config option. Two details decide whether it works:

  • Each step must be independently deployable and independently revertable. If step 2 cannot be rolled back without data loss, it is not a step.
  • The wait in step 3 is bounded by the oldest thing that might still read the old form — a paused consumer, a replayed topic, a backup restore, a mobile client on an old version. That is usually much longer than the deploy window, and it is the step teams skip.

Sources

  1. docsProtocol Buffers — Updating a message type (proto3)
  2. docsApache Avro — Schema Resolution
  3. docsConfluent — Schema Evolution and Compatibility types
  4. articleFowler — ParallelChange (expand–contract)

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close