Skip to content
advanced Phase 8 · Messaging and Event-Driven Architecture

Schema Evolution and Compatibility

Change event contracts without breaking older producers or consumers.

50m
0 problems
Topic Progress 0%

Backward or Forward? Pinning Down Which Direction Your Promise Points

Two engineers argue about whether a change is 'compatible' and both are right, because they are measuring different directions. Compatibility is not a property of a change — it is a property of a change relative to a direction of reading. Fix the vocabulary first, because every registry, every RFC, and every interview question hangs on it:

writer schema: the schema a producer used when it serialized the record
reader schema: the schema a consumer uses when it deserializes

BACKWARD compatible: a NEW reader schema can read data written by OLD writers
   ⇒ new consumers may read old data  ⇒ deploy new consumers FIRST, producers later
FORWARD compatible:  OLD reader schemas can read data written by a NEW writer
   ⇒ old consumers survive new producers ⇒ deploy producers FIRST... or never break them
FULL compatible:     both of the above

The direction that trips everyone: backward sounds like 'old code reads new data', and it is the opposite. Anchor it with the deployment consequence — backward compatibility is what lets you ship the consumer upgrade before the producer upgrade, which is the order you can always control within one release train. Forward compatibility is the harder promise (old binaries must tolerate data they have never seen), which is why registries often default to BACKWARD and why a system that must never break slow consumers asks for FORWARD or FULL.

Work a concrete matrix, because this is where interviews test understanding:

Change Backward? Forward? Why
Add optional field with default ✓ ✗* new readers fill the default on old data; *old readers reject unknown fields unless configured to ignore them
Add field, old readers ignore unknowns ✓ ✓ both directions tolerate the extra data
Remove a field ✓ ✗ new readers ignore data they do not declare; old readers still expect it unless their own schema declares a default
Rename in place (old name dropped) ✗ ✗ to the new reader the field never existed — no default, no match
Rename done additively (add new, keep old) ✓ ✗ behaves like an add: new readers default on old data, old readers never see their field in new data
Change field type int → long ✓ ✗ widening reads old small values; old readers choke on large ones

Two subtleties that separate a strong answer from a passable one. First, JSON Schema's default additionalProperties handling decides forward compatibility for you: strict validators reject unknown keys (forward-breaking), while lenient ones drop them (forward-safe). Second, a table like this is only a hypothesis: compatibility is proven against real retained payloads — encode old data with the new schema and replay it through the new reader — never against developer intuition.

The wire format mechanics explain why any of this works at all. Avro serializes without field names in the payload — it writes fields positionally against the writer's schema, which the registry identifies by schema id in the record envelope; the consumer resolves that id, gets the writer's schema, and applies the reader's schema field-by-field to map old names/positions to new ones. Protobuf writes field numbers, so name changes are invisible on the wire, and unknown fields are preserved-but-skipped by default — that is Protobuf's built-in forward tolerance. JSON with no schema id at all gives you neither guarantee, which is exactly why untyped JSON events become a liability once consumers outside your team exist.

Interview formulation: define both directions in terms of reader-versus-writer versions, give the deployment order each permits, and name one edit that is backward-safe but forward-breaking (add-with-default). If you can explain why — id-or-field-number driven deserialization — you have answered the follow-up before it is asked.

The Safe Edit List: What You Can Ship on a Tuesday Without Coordinating Deploys

The goal of a compatibility policy is to let teams deploy independently. That only happens if a known set of edits is boringly safe — and the rest is gated. Here is the edit list, with the reasoning, ordered from 'ship it' to 'never alone':

SAFE (backward, usually full):
  + add a NEW optional field with a default
  + add a new event type / new topic
  + widen a numeric type (int → long/decimal) where readers accept it
  + add a new enum value IF readers treat unknown values as 'other'

NEEDS A ROLLOUT PLAN:
  - remove a field          → mark deprecated first, stop reading, then remove in v2
  - narrow a type (long→int) → truncation risk for out-of-range values
  - rename a field          → additive: add new + duplicate old, migrate, drop old

NEVER IN PLACE (breaking):
  × change a field's type incompatibly (string → object, int → string)
  × reuse a removed Protobuf field number
  × reorder/reuse positions in an Avro union
  × tighten validation (making an optional field required)

Each safe row has a mechanism worth stating. Add-with-default is safe backward because the reader fills the gap, and safe forward only if old readers are configured to ignore unknown fields — check that config, do not assume it. Additive renames (add fullName, keep writing name, migrate readers, stop writing name months later) convert a breaking rename into two safe steps — the same expand/contract discipline as a database migration. Enum additions need the 'unknown → OTHER' rule because an old consumer with an exhaustive switch on PaymentMethod will throw on the new value; if your code has switch without default, your schema policy must forbid enum additions until that code is fixed.

Numbers to know for Protobuf, since it comes up constantly:

message Payment {
  reserved 3;              // old field 'discount' removed — number NEVER reused
  reserved "discount";     // name also reserved so it can't return as a new field
  int64 amount_cents = 1;  // numbers are the contract; rename freely
}

Reusing number 3 means old producers' bytes get misread as the new field's type — silent corruption, not an error. Reserving is the difference between a schema history and a landmine. Avro's equivalent trap is union position: appending a new type to a union is safe; changing the order is not.

Governance makes the list real. A registry with compatibility enforced per subject (TopicNameStrategy, RecordNameStrategy, or None) rejects unsafe registrations at push time — but the check that actually catches regressions runs in CI: compile the new schema against the previous version, replay last week's captured payloads through the new reader, and run consumer tests against producer fixtures from the previous release. Teams also need a deprecation ritual: mark the field deprecated in the schema, monitor whether any consumer still reads it (registry lookup by field or a decoder-side counter), announce removal in a changelog with a date, then remove in a versioned event. Skipping the monitoring step is how 'unused' fields turn out to have three consumers you never met.

A governance detail interviewers appreciate: compatibility is per subject, not per platform — two topics on one Kafka cluster can have different policies, and one topic carrying two event types needs RecordNameStrategy so each type's evolution is checked independently. When a change genuinely cannot be made compatible, you do not relax the policy — you version the contract, which is the final chapter's subject.

Shipping a Breaking Change: Versioned Topics and the Expand/Contract Dance

The change: customer: string becomes customer: {id, name} — a structure change that fails both directions. The business wants it in three weeks and consumers cannot all deploy in the same window. Compatibility policy says no; the deadline says yes. The resolution is that breaking changes do not go through the compatibility gate — they go through versioning, in one of three standard shapes.

Shape 1 — dual publishing (the safe default). Deploy a producer that writes the new event to orders.v2 while continuing to write orders.v1. Consumers migrate at their own pace on orders.v1 → orders.v2; when lag on v1 hits zero and the last consumer is gone, stop v1. Costs: double write volume, and every consumer must eventually switch — you are buying time, not coordination-free migration.

Shape 2 — expand/contract (parallel change), for in-place evolution. The three phases, which are the same discipline as zero-downtime database migrations:

EXPAND     1. consumers accept BOTH old and new shapes (readers tolerant,
              defaults applied) — deploy this everywhere first
           2. producers start emitting the new shape; registry enforces
              backward compatibility so old readers still work
MIGRATE    3. wait for lag/drain; monitor which readers still see old shape
CONTRACT   4. remove old-shape handling in a later release — only after
              nothing emits or requires it

The failure mode to name in an interview: skipping phase 1. That is the Friday incident — producer ships first, consumer that cannot parse it crashes, topic backs up. Expand/contract exists precisely to make every intermediate state a state some version of every component already understands.

Shape 3 — a transformation at the boundary. A compactor, a stream-processing job, or an adapter consumer converts v1 → v2 on the fly (Kafka Streams/Connect with SMTs, or a small service). Use it when dual publishing is unacceptable (volume) or when old producers will exist for months (mobile clients!). Mobile is the canonical unpublishable-producer case: your 'old version' is in customers' pockets for a year, so the API must be forward-tolerant by policy and structurally versioned (/v2/ paths, Accept headers, event typeVersion fields).

Operational checklist for the migration window:

Check Signal
Old consumers drained? v1 group lag flat at 0 with no reads for a full retention window
Any producer still on old shape? registry subject versions + producer metrics by schema id
Readers tolerating both? consumer error rate on deserialization ≈ 0 during expand
Rollback plan? producers can flip back to old emission — expand phase keeps it one-line
Consumers of consumers? downstream projections from v1 may lag — trace one level further

Also decide what happens to records already in the log when a consumer encounters a schema id it cannot resolve (registry outage, subject deleted): fail fast to a deserialization DLQ with the id preserved, never skip-and-continue silently — silent skips become unexplained data gaps in someone else's dashboard weeks later.

Interview summary to deliver: classify the change; if additive, ship it behind compatibility enforcement; if breaking, version the contract with dual publish or expand/contract, monitor drain, then contract — and gate all of it in CI with payload replay. From data contracts, the roadmap turns to choosing the store itself: Relational vs NoSQL Selection.

Quiz

1. A registry enforces BACKWARD compatibility. In which order can you safely deploy a change that adds an optional field with a default?

Question 1 options

2. A Protobuf field `repeated string tags = 3` is removed. What must the team do before assigning number 3 to a new field?

Question 2 options

3. You must change customer: string to customer: {id, name} with consumers deploying over two weeks. Which rollout is correct?

Question 3 options

4. A consumer meets a schema id in the record envelope that the registry cannot resolve. What is the correct behavior?

Question 4 options

Flashcards

Question

Backward vs forward compatibility — state each in reader/writer terms and the deploy order it permits.

Answer

Backward: a new reader schema can read data written by old writers ⇒ ship consumers first, producers second. Forward: old readers can read data written by a new writer ⇒ producers may move first without breaking deployed consumers. Full: both. (Backward is not 'old reads new' — anchor it to the consumer-first rollout.)

Question

Why is adding an optional field with a default the safest production change?

Answer

New readers fill the missing value from the default when reading old data (backward-safe), and old readers tolerate the extra field only if configured to ignore unknowns (forward-safe). It changes no semantics for existing consumers, requires no coordinated deploy, and passes every registry's compatibility mode when defaults are declared.

Question

Why do Protobuf renames break nothing while renames in JSON do?

Answer

Protobuf serializes field numbers, not names, so a name change never appears on the wire — numbers are the contract (never reuse a removed number; reserve it). JSON carries names literally in every payload, so a rename makes old records unparseable to new readers unless you keep the old field alongside the new one during migration.

Question

The three phases of expand/contract, and the incident skipping phase 1 causes

Answer

Expand: deploy readers that accept both old and new shapes (then writers emit new). Migrate: monitor drain and who still sees the old shape. Contract: remove old-shape handling in a later release. Skipping expand means the new producer ships to readers that cannot parse it — deserialize errors, topic backup, and an outage that looks like a bug rather than a rollout-order mistake.

Question

Dual publish on .v1/.v2 topics: what does it buy and what does it cost?

Answer

It buys independent migration timelines: producers write both, consumers move at their own pace, and v1 stops only when its group lag stays at zero with no readers. Costs are doubled write/storage volume, a mandatory end state where every consumer has moved, and downstream projections (consumers of consumers) that must be traced too.

Revision Notes

Key Takeaways

  • 1. Compatibility is directional: backward protects new readers, forward protects old ones, and each direction implies a specific deploy order
  • 2. Additive optional fields with defaults are the only universally boring change; renames become safe only when split into add-migrate-drop
  • 3. Field numbers and schema ids are the real wire contract — names matter only where the format carries them, and removed identifiers must be reserved forever
  • 4. Breaking changes bypass the compatibility gate into versioning: dual publish or expand/contract, each with a drain signal before the contract phase
  • 5. Gate compatibility in CI with real payload replay, and fail loudly on unresolved schema ids — silent skips become other people's data gaps

Interview Tips

  • • "Backward or forward compatible — define both" — new reader/old data and old reader/new data, then immediately give the deploy order each permits; the reader-writer framing proves you are not guessing
  • • "Add a field to an existing event" — optional with default, consumer-first rollout, unknown-field config check for the forward direction, and registry enforcement per subject
  • • "We must rename a field safely" — additive duplicate with deprecation, monitor which readers still consume the old one, dated changelog, then contract; contrast with Protobuf where renames are free
  • • "Walk me through a breaking migration" — dual publish or expand/contract with the drain signals table, rollback line, and the CI replay step; mention mobile clients as the unpublishable-producer case

Cheat Sheet

Schema Evolution & Compatibility

Directions (reader-centric):

BACKWARD  new reader reads OLD data      → consumers deploy FIRST
FORWARD   OLD reader reads NEW data      → producers can move first
FULL      both
Edit B F Note
add optional field + default ✓ ~ forward only if old readers ignore unknowns
rename field ✗ ✗ do additive: add new, keep old, migrate, drop
remove required field ✗ ✓ deprecate → monitor → remove in v2
int → long (widen) ✓ ✗ old readers choke on big values
enum + value ✓ only with unknown→OTHER handling exhaustive switches break
Protobuf number reuse ✗ ✗ reserved 3; reserved "name"; forever

Wire mechanics: Avro = schema id in envelope + writer/reader schema mapping · Protobuf = field numbers (rename-safe, unknown fields skipped) · plain JSON = names only, no guarantee.

Breaking change rollout:

dual publish:  emit v1 + v2 → consumers migrate → drain v1 lag → stop v1
expand/contract: readers tolerate BOTH → writers emit NEW → monitor drain → drop old support
mobile/unpublishable producers → version at the boundary (/v2, typeVersion) + transforms
Gate during migration Signal
old consumers drained v1 lag = 0, no reads for a retention window
writers on new shape only registry subject versions + metrics by schema id
rollback still one-line expand phase keeps old emission working

Rules: compatibility enforced per subject in the registry (Topic vs Record name strategy) · CI replays last week's payloads through the new reader · unresolved schema id → DLQ with id + bytes, never skip silently · deprecations need field-level monitoring + a dated changelog.

Continue Learning