2.10
Schema Evolution and Compatibility
Changing a message format without breaking old readers or writers: backward, forward and full compatibility.
40 min Reading + animations
Tip: tap or hover any dotted-underlined term to see what it means.
By the end of this lesson you will be able to
- Explain why changing a message or record format is hard in a distributed system: rolling deploys, independent teams, rollbacks and data that lives for years.
- Define backward, forward and full compatibility (and their transitive variants) precisely, from the point of view of the writer schema and the reader schema.
- Decide who must deploy first for each compatibility mode, and recover when the order was wrong.
- Apply the evolution rules of Protobuf (field numbers,
reserved, type changes, enums), Avro (defaults, aliases, unions, promotions), JSON (tolerant readers, JSON Schema) and Thrift. - Rename a field, change a type or remove a field without an outage, using expand–migrate–contract.
- Describe how a schema registry checks compatibility and what the 5-byte prefix on every record means, and how event-sourced systems version events with upcasters.
- Catch breaking changes in CI with compatibility checks, breaking-change linters and consumer-driven contract tests.
#Where this lesson fits
Lesson 2.9 taught how data is turned into bytes: JSON and XML with names in every record, Protobuf and Thrift with field numbers, Avro with values only and a writer schema needed to read them. This lesson is about time: what happens when that format changes while old bytes and old programs still exist.
The format between two programs is a contract, the same way the request and response format is the contract between a client and a server (lesson 1.1). Changing one side of a contract is easy. Changing it while the other side keeps running, without stopping anything, is the whole subject here. Lesson 1.2 showed the rolling deploy (what is a server) and lesson 1.13 the load balancers behind it (web and app servers): those deploys are exactly when old and new formats meet.
Schema evolution is the ability to keep reading and writing data while its schema changes, without stopping the system or rewriting everything that already exists. A schema is the blueprint of the data: which fields exist, their types, which are optional and what their defaults are.
#Why schemas change, and why it is hard
Systems change every week. A table that has four fields today gets a fifth tomorrow, or loses one. An event gains a currency, an API response gets a new nested object, an ID becomes a string. On one machine you would change the code and the data together. In a distributed system you cannot, for four reasons:
| Where old and new meet | Why both versions exist at once | How long |
|---|---|---|
| Rolling deploys | Instances are replaced a few at a time, so v1 and v2 serve traffic side by side and read each other’s writes | Minutes to hours |
| Independent teams | The producer of an event and its five consumers are owned by different teams with different release days | Days to months |
| Stored data | Database rows, Kafka topics with long retention, files in a data lake, cache entries and messages waiting in queues were written by older code | Seconds (a queue) to forever (an event store, a compacted topic) |
| Clients you do not control | Mobile apps stay on old versions for years; partners call your public API with code they wrote once | Years |
And one more that teams forget: rollbacks. If v2 wrote data and you roll back to v1, old code must read new data.
A rolling deploy: v1 and v2 write and read the same data
During a deploy, and for as long as old data exists, both versions meet. Play each change and watch who can still read what.
Scenario
v2 adds currency with a default. Old and new events mix in the topic, and every reader copes.
A rolling deploy replaces instances a few at a time, so for minutes (hours in a big fleet) v1 and v2 serve traffic side by side. There is no moment when everything switches at once.
Three lessons from the animation carry through the whole topic:
- Additive changes are the safe kind: a new optional field with a sensible default.
- A rename is a remove plus an add in any format where names are the contract (JSON, Avro). Done in one step, it breaks somebody.
- Readers that write data back must keep fields they do not understand, or a rollback, or a v1 instance in a mixed fleet, quietly deletes data.
#Backward, forward and full compatibility
Every compatibility question has two schemas in it: the writer schema that encoded the data and the reader schema the code uses to decode it. The names describe the relationship from the point of view of the new schema:
Backward compatibility : new code (new schema) can read OLD data
Forward compatibility : old code (old schema) can read NEW data
Full compatibility : both backward and forward
- Backward compatible change: a reader with the new schema can read data written with the old one. Typical way: add a new field as optional or with a default, so old records that lack it still decode.
- Forward compatible change: a reader with the old schema can read data written with the new one. This works because old readers ignore fields they do not know. Removing a field is the dangerous move here: it breaks old readers that still need it, unless that field had a default in their schema.
- Full compatible change: both. In practice, only adding or removing optional fields that have defaults.
- Breaking change: neither, for example changing a field’s type from a number to a string.
A schema registry turns these into modes that it enforces on every new version:
| Mode | The new schema must… | Checked against | Who deploys first |
|---|---|---|---|
| BACKWARD (Confluent’s default) | read data written with the previous schema | The latest version | Consumers |
| BACKWARD_TRANSITIVE | read data written with every earlier schema | All versions | Consumers |
| FORWARD | write data the previous schema can read | The latest version | Producers |
| FORWARD_TRANSITIVE | write data every earlier schema can read | All versions | Producers |
| FULL | do both, with the previous schema | The latest version | Either |
| FULL_TRANSITIVE | do both, with every earlier schema | All versions | Either |
| NONE | anything goes, no check | Nothing | Coordinate by hand |
What each mode allows depends on the format (an Avro field without a default behaves differently from a Protobuf field), but for Avro the rule of thumb is: BACKWARD allows deleting fields and adding fields with defaults; FORWARD allows adding fields and deleting fields that had defaults; FULL allows only adding or deleting fields with defaults.
Backward, forward, full: which version reads which data
The names are from the point of view of the new schema. Backward: the new schema reads data written with the old one. Forward: the old schema reads data written with the new one.
Scenario
v2 adds currency with default BDT. Can v2 read what v1 wrote last year?
Last year the writer used v1. Those records are still stored.
#Transitive: compatible with every version, not just the last
The plain modes compare a new schema only with the latest one. That protects consumers that read near the head of a topic. It does not protect anyone who reads old data: a new consumer that replays a topic with infinite retention, a compacted topic that keeps the newest record per key forever, an event store, or Avro files in a data lake. Compatibility is not transitive: v3 can be compatible with v2, and v2 with v1, while v3 is not compatible with v1.
Transitive compatibility: a new consumer replays years of events
v1 (2024): amount is a double. v2 (2025): amount removed. v3 (2026): amount comes back as a string with default "0". Each step looked fine next to the one before it.
Scenario
The registry compares v3 only with v2.
A v3 reader reading v2 data: amount is missing, the default "0" fills it. Backward compatible with the latest version, so v3 is registered.
Use the _TRANSITIVE modes for any data that is kept and re-read. Use the plain modes only where data is consumed quickly and never replayed.
#Who deploys first
The compatibility mode is a promise about one direction. The deploy order must follow it, because the registry checks the schema, not your deploy order:
| Mode | Safe order | Why | What breaks if you get it wrong |
|---|---|---|---|
| BACKWARD | Consumers first, then producers | New readers can read old and new data; old readers are not promised anything about new data | A producer that goes first writes data old consumers cannot read: deserialization errors, stuck consumers |
| FORWARD | Producers first, then consumers | Old readers can read new data; new readers are not promised anything about old data | A consumer that goes first, or one that rewinds to old data, cannot read it |
| FULL | Either | Both directions hold | Nothing, which is why FULL is popular for shared topics |
Who deploys first: the compatibility mode decides
v2 drops couponCode, which the v1 reader schema needs (no default). The registry runs in BACKWARD mode, so it accepts v2: a v2 reader can read v1 data. Nothing promises that v1 readers can read v2 data.
Scenario
Shipping upgrades first, then the producer.
Under BACKWARD, the new reader is the side guaranteed to cope with both shapes, so it goes first.
Two consequences engineers learn the hard way:
- Rollbacks need forward compatibility. Suppose a producer change is only backward compatible, the producer ships, writes data, and then has to be rolled back. The old producer code is now a reader of new data, which BACKWARD never promised. If you want a free rollback, the change must be compatible in both directions (FULL), or you ship it in two releases: first teach every reader the new shape, then start writing it.
- Rolling back the producer does not remove what it wrote. Bad records stay in the topic. The way out is usually forward: ship a reader that can handle them, or skip them deliberately (Chapter 30 covers dead-letter queues).
Self-checkQuestion
Your registry subject is in FORWARD mode and a new schema version was just accepted. Which deploy order is safe?
#The rules, format by format
The rules differ because the formats identify fields differently (lesson 2.9): Protobuf and Thrift by number, Avro by name with the writer schema at hand, JSON by name with no schema at all.
#Protobuf: never touch a field number
On the wire every Protobuf field is (field number, wire type, value); names never travel. Each side compiles its own copy of the .proto, so they agree only on numbers. The rules follow from that:
- Never change a field’s number, and never reuse a number. Data written with the old meaning will be read with the new one. This does not corrupt the whole message, but the field it hits is silently misread or lost, which is often worse because nothing fails.
- Adding a field is safe in both directions. Old readers skip the unknown field (the wire type tells them how many bytes to skip) and, since Protobuf 3.5, proto3 keeps unknown fields and writes them back out when the message is re-serialized. New readers see the default (zero, empty string, false) when old data lacks the field.
- Deleting a field is safe if you reserve it. Remove the field and reserve both its number and its name so nobody can bring them back with a new meaning:
message OrderPlaced {
reserved 3; // was coupon_code, removed 2026-03
reserved "coupon_code";
string order_id = 1;
int64 amount_paisa = 2; // money as integer minor units, never double
string currency = 4; // added in v2; empty means BDT
optional string gift_note = 5; // optional: “not sent” differs from “empty”
}
- Renaming is safe on the binary wire (names are not sent) but breaks anything that uses names: the JSON mapping of Protobuf, the text format, field masks, and every line of code that refers to the old name. Treat a rename as an API change for any JSON clients.
- Changing a type is mostly unsafe. A few groups share a wire encoding and can be swapped, with care:
int32,uint32,int64,uint64andbool(values may be truncated, as in a C cast);sint32andsint64;stringandbytes(if the bytes are valid UTF-8);fixed32andsfixed32;fixed64andsfixed64. Anything else, such asint64tostring, gives a wire-type mismatch: most libraries then treat the value as an unknown field, so it silently reads as empty. - Enums are numbers. In proto3 enums are open: an old reader that receives a new value (say 4) keeps the number even though it has no name for it. The wire survives, but code with a
switchand no default branch does not. Always handle unknown values, and make value 0 an..._UNSPECIFIEDplaceholder so a missing value is never mistaken for a real one. requiredis gone. proto2’srequiredturned every later removal into a breaking change, so proto3 dropped it (Protobuf Editions keep it only as a legacy feature for old files). Validate presence in code instead. Useoptionalin proto3 (supported since 3.15) when you must tell “not set” from the default.- Be careful with
oneofand with changing a single field torepeated. Moving existing fields into or out of aoneof, or changing cardinality, can lose data in some languages; prefer adding a new field.
Protobuf: the field number is the contract
Protobuf sends each field as (field number, wire type, value); names never travel. Reader and writer each compile their own copy of the .proto, so they agree only on numbers.
Scenario
The new writer adds string gift_note = 5. The reader still has the old .proto.
Each field is prefixed by a tag byte holding its number and wire type. For field 5 the wire type says “length-delimited”, followed by the length.
#Avro: defaults and names
Avro bytes contain no names and no numbers, just values in the order of the writer schema. To read, the reader needs the exact writer schema (from the file header of an Avro container file, or from a schema registry), then resolves it against its own reader schema, field by field, by name:
- A field in the writer but not in the reader: skipped.
- A field in the reader but not in the writer: the reader’s default is used; with no default, reading fails.
- A field in both: the types must match or be a legal promotion:
int→long,float,double;long→float,double;float→double;string↔bytes.
So the Avro rules are:
- Every added field needs a default (for backward compatibility: new readers on old data).
- Only remove fields that had a default (for forward compatibility: old readers fill in the default when the field disappears).
- Rename with
aliasesin the reader schema. An alias makes a rename backward compatible only: old readers still look for the old name. Upgrade every reader before any writer. - Optional means a union with null, and the default must match the first type in the union, so put
"null"first:{"name": "note", "type": ["null", "string"], "default": null}. - Enums: adding a symbol breaks old readers unless their enum declares a
defaultsymbol (supported since Avro 1.9).
The notes’ example schema, with one correction:
{
"type": "record",
"name": "OrderCreated",
"fields": [
{ "name": "orderId", "type": "string" },
{ "name": "amount", "type": { "type": "bytes", "logicalType": "decimal", "precision": 12, "scale": 2 } },
{ "name": "currency", "type": "string", "default": "BDT" }
]
}
The default on currency is what lets a new reader read old events that have no currency. The notes typed amount as a double: never store money in binary floating point (0.1 + 0.2 is not 0.3). Use a decimal logical type or an integer count of the smallest unit (paisa, cents).
Avro: the reader resolves the writer’s schema by field name
Avro bytes carry no names and no numbers, only values in the writer schema’s order. To decode, the reader needs the exact writer schema, then matches fields by name against its own.
Scenario
Writer v1 {orderId, amount}; reader v2 adds currency with default BDT (the notes’ schema).
The writer encodes two values back to back. Nothing in the bytes says which field is which.
#JSON: tolerant readers and weak schemas
JSON has no schema on the wire, so the contract is the field names and the types of their values, and compatibility depends entirely on how readers are written. The rule is the tolerant reader: read only the fields you need, ignore everything else, and treat a missing optional field as its default.
| Library | Unknown fields by default | Note |
|---|---|---|
Java Jackson ObjectMapper | Fails (FAIL_ON_UNKNOWN_PROPERTIES is true) | Spring Boot’s auto-configured mapper turns it off; plain Jackson does not |
Go encoding/json | Ignored | Decoder.DisallowUnknownFields() makes it strict |
| Python Pydantic v2 | Ignored (extra='ignore') | extra='forbid' makes it strict |
TypeScript JSON.parse + a type | Ignored (no runtime check at all) | Missing fields become undefined at run time, whatever the type says |
Common JSON traps: a rename (amount → total) looks like a removal to every reader; a number that becomes a string ("amount": "FIFTEEN HUNDRED" in the notes) breaks typed readers; null, a missing key and an empty string mean different things to different languages; and 64-bit IDs lose precision in JavaScript numbers (lesson 2.9), which is why many APIs send IDs as strings from day one.
JSON Schema describes and validates JSON, but it was built for validation, not evolution, so registry compatibility checks for it are weaker and easy to misread. The key switch is additionalProperties:
- Closed (
"additionalProperties": false): any new field fails validation in old readers, so every addition is forward incompatible. - Open (the default): additions pass, but a rename also “passes”, because the old name simply becomes an allowed extra property. The checker cannot see that a required meaning disappeared.
Many teams keep JSON for public APIs (readability, universal support) and use Avro or Protobuf with a registry for internal events, where the stronger rules pay off.
#Thrift
Thrift identifies fields by numeric IDs, like Protobuf, so the same rules apply: never change or reuse an ID, add new fields as optional with new IDs, and leave a comment listing retired IDs so nobody reuses them. Old readers skip unknown fields (and usually do not keep them). Avoid required: the Thrift community’s rule is “required is forever”, because removing it later breaks every old reader.
#Change-by-change cheat sheet
| Change | Protobuf | Avro | JSON (tolerant reader) |
|---|---|---|---|
| Add an optional field | ✅ both ways | ✅ both ways if it has a default | ✅ both ways (❌ for strict readers) |
| Add a field without a default | n/a (all proto3 fields have one) | ❌ backward (new reader on old data fails) | ⚠️ readers must cope with it missing |
| Remove a field | ✅ if number and name are reserved | ✅ backward; forward only if it had a default | ❌ for readers that still need it |
| Rename a field | ✅ binary, ❌ JSON mapping and code | ⚠️ backward only, with aliases | ❌ (a remove plus an add) |
| Change a type | ❌ except compatible groups | ❌ except promotions (int → long…) | ❌ |
| Reuse a field number or name | ❌ silent corruption | ❌ old data takes the new meaning | ❌ old data takes the new meaning |
| Add an enum value | ⚠️ wire OK, code must handle unknown | ❌ for old readers without an enum default | ⚠️ code must handle unknown strings |
#A schema registry, at overview level
When dozens of services share events, someone has to hold the schemas and enforce the rules. A schema registry is a central service that stores every version of every schema, assigns each one an ID, and checks each new version against the subject’s compatibility mode. It matters most in the Kafka ecosystem. Here is the shape of it; Chapter 32 covers the registry in full.
The notes’ motivating example: the Order service sends {"orderId": "ORD-101", "amount": 1500} and the Inventory service consumes it. Adding "currency": "BDT" may or may not hurt Inventory, depending on how it reads; changing to {"orderId": 101, "amount": "FIFTEEN HUNDRED"} changes both types and will break old consumers. A registry turns “may or may not” into a check that runs before the change ships.
How it works (Confluent Schema Registry, the most common one):
- Register in CI. The pipeline submits the new schema for a subject (by default named after the topic, such as
orders-value). The registry checks it against the subject’s mode (BACKWARD by default) and either assigns an ID or refuses it with409 Conflict. - Produce. The producer’s serializer looks up the schema’s ID once and caches it. Each record is written as magic byte
0+ the 4-byte schema ID + the encoded body (Protobuf records also carry message indexes after the ID). Five bytes instead of the whole schema in every message. - Consume. The consumer’s deserializer reads the ID, fetches that schema once (
GET /schemas/ids/{id}) and caches it, then decodes with the writer schema and resolves into its own reader schema.
Data never flows through the registry. It is a lookup service on the side, used when a client meets a schema it has not cached. The notes’ diagram draws the registry inline between producer and topic; that is the corrected picture.
Schema registry: schemas out of band, a 5-byte prefix on every record
Overview of the Confluent-style flow; Chapter 32 goes deeper. Records never pass through the registry: clients fetch schemas from it and cache them.
Scenario
The first record with schema v2, then the steady state.
The serializer asks the registry for the ID of its schema under the subject (by default “topic-value”). In production the schema was registered earlier by CI, not by the app.
What the registry gives you: a single contract between producers and consumers, fewer type mismatches, a version history, safe deploys and breaking changes caught early. What it costs: one more tier-0 service (new pods cannot start while it is down), IDs that only mean something inside one registry (copying topics between clusters means copying schemas too), and discipline: turn off auto-registration in production (auto.register.schemas=false) so only the reviewed pipeline can add schemas.
Other registries follow the same idea with their own details: Apicurio Registry (compatible with Confluent’s API), AWS Glue Schema Registry (modes such as BACKWARD_ALL, and its own header containing a schema version UUID instead of the 5-byte prefix), and the Buf Schema Registry for Protobuf modules.
#Event versioning and upcasting
Event sourcing stores every change as an immutable event and rebuilds state by replaying them (Chapter 32). That makes evolution stricter than anywhere else: the events are the source of truth, they are never edited, and a replay reads every version ever written, years later. The usual tools:
- Compatible evolution first. Add fields with defaults; never change the meaning of a field. Most changes should need nothing more.
- Version in the event: an explicit
schemaVersion(orOrderPlaced.v2as the type) so the reader knows what it holds. - Upcasting: when the shape must change, write a small pure function that turns a v1 event into a v2 event while reading. Handlers see only the latest version; the stored event is untouched. Upcasters chain (v1 → v2 → v3).
- Copy and transform: rarely, write a new stream with every event converted, and switch over. Expensive, but it lets you drop old upcasters.
- Test the replay: keep a sample of every historical event version and replay it in CI. A missing upcaster only shows up on the day you rebuild a projection or recover from a disaster.
Event sourcing: upcasting old events on read
In an event-sourced system the events are the source of truth and are never edited. When the order event changes shape, old events must still replay. Overview here; Chapter 32 covers event sourcing.
Scenario
v1 stored amount as 1500.0 taka; v2 stores amountPaisa as an integer plus currency.
A projection is rebuilt from event 1. The oldest events are in the v1 shape.
#API versioning: the same rules at the edge
A REST or gRPC API is a schema whose readers are other people’s code, so the same logic applies (Chapter 26 covers API design and versioning in depth):
- Additive changes need no new version: new optional request fields, new response fields, new endpoints. This only works if clients are tolerant readers, which is worth saying in your API documentation.
- Breaking changes get a new version: a path (
/v2/orders), a header or a media type. Some providers version by date and translate old requests into the new shape on the server, which is upcasting for an API. - Retire old versions loudly: announce a date, send the
Deprecationheader (RFC 9745) and theSunsetheader (RFC 8594), measure who still calls the old version, and only then remove it. - Mobile clients are the long tail: an app version installed today may call your API for years, so public response formats deserve FULL-style care.
#Database schemas: the same idea, expand and contract
A database table is a schema too, and a rolling deploy means old and new application code query it at the same time. The safe pattern is expand → migrate → contract, which you already saw in the rename scenario:
- Expand: add the new column (nullable or with a default). Old code ignores it.
- Migrate: deploy code that writes both columns, backfill old rows in batches, then switch reads to the new column.
- Contract: once no running code uses the old column, drop it.
Each step is backward and forward compatible with the code running next to it. Chapter 46 covers zero-downtime migrations in detail.
#Catching breaking changes before production
Rules nobody checks are suggestions. Put these in CI:
- Schema compatibility checks: ask the registry before merging (
POST /compatibility/subjects/{subject}/versions/latest), or use the build plugins that do the same. - Breaking-change linters:
buf breakingcompares.protofiles against the main branch and fails on a reused number, a removed field withoutreserved, a type change and more (rule sets for wire-only, wire plus JSON, or full source compatibility). - Golden samples: keep encoded examples of every old version and assert the new code still reads them.
- Consumer-driven contract tests (Pact is the best-known tool): each consumer publishes what it actually uses, the provider’s build verifies its real messages against every consumer’s contract, and a
can-i-deploycheck blocks a release that would break a consumer running in production. A schema check knows the types; a contract test knows what each consumer depends on.
Consumer-driven contract tests: catch a breaking change before deploy
A schema check knows the types; a contract test knows what each consumer actually uses. Both run in CI.
Scenario
Billing records what it needs from the order event.
Billing’s own tests produce a contract: the fields and types it reads, nothing more. It is published with Billing’s version.
#Schema evolution in system design
Choosing a mode:
| Data | Recommended | Why |
|---|---|---|
| Kafka topic read by many teams, replayed, or compacted | FULL_TRANSITIVE (or BACKWARD_TRANSITIVE) | Any deploy order, any reader of any age |
| Short-lived queue, consumed in minutes, never replayed | BACKWARD or FULL | Only recent versions are ever read |
| Event store (event sourcing) | Transitive plus upcasters | Every version is replayed forever |
| Internal gRPC between services | Protobuf rules plus buf breaking | Nothing stored; rolling deploys need both directions |
| Public REST API | Additive only, version on breaks, deprecation headers | You do not control the clients |
| Development topics | NONE | Speed, but never for shared data |
Failure signatures to recognise:
| Symptom | Likely cause |
|---|---|
| Consumer stuck at one offset, deserialization errors, lag growing | Producer deployed a change its old consumers cannot read (wrong deploy order) |
| A field is suddenly empty or zero, no errors | Rename read by a lenient parser, or a wire-type mismatch in Protobuf |
| Nonsense values in one field of old records | A field number or name was reused with a new meaning |
| A new consumer or a replay fails, live consumers are fine | Non-transitive compatibility; old data in the topic |
| New pods fail to start, running ones fine | Schema registry unreachable, cold caches |
| Data loses a field after a rollback | Old code rewrote records without keeping unknown fields |
Interview angles: “How do you rename a field in an event used by twelve teams?” (expand–migrate–contract, never in one step). “What is the difference between backward and forward compatibility, and which deploys first?” “Why does Protobuf have reserved?” “How do you evolve events in an event-sourced system?” (compatible changes, versions, upcasters). “Why is a schema ID in every Kafka record, and what if the registry is down?” Good answers always name who reads, who writes, and in which order they deploy.
#Try it yourself
Check a Protobuf change against the main branch with Buf (it fails on a reused number or a removed field that was not reserved):
buf breaking --against '.git#branch=main'
# output similar to:
# order.proto:12:3:Field "3" on message "OrderPlaced" changed name from "coupon_code" to "shipping_note".
Ask a schema registry whether a schema would be accepted, without registering it (Confluent API, local test registry):
curl -s -X POST -H 'Content-Type: application/vnd.schemaregistry.v1+json' \
--data '{"schema": "{\"type\":\"record\",\"name\":\"OrderCreated\",\"fields\":[{\"name\":\"orderId\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"string\"}]}"}' \
'http://localhost:8081/compatibility/subjects/orders-value/versions/latest?verbose=true'
# {"is_compatible":false,"messages":["... the reason, naming the field amount and both types ..."]}
curl -s http://localhost:8081/config/orders-value # the subject's mode
curl -s -X PUT -H 'Content-Type: application/vnd.schemaregistry.v1+json' \
--data '{"compatibility": "FULL_TRANSITIVE"}' http://localhost:8081/config/orders-value
Watch Avro schema resolution in Python with fastavro: write with v1, read with v2.
import io
from fastavro import parse_schema, schemaless_writer, schemaless_reader
v1 = parse_schema({"type": "record", "name": "Order", "fields": [
{"name": "orderId", "type": "string"},
{"name": "amount", "type": "long"}]})
v2 = parse_schema({"type": "record", "name": "Order", "fields": [
{"name": "orderId", "type": "string"},
{"name": "amount", "type": "long"},
{"name": "currency", "type": "string", "default": "BDT"}]})
buf = io.BytesIO()
schemaless_writer(buf, v1, {"orderId": "o-101", "amount": 150000})
buf.seek(0)
print(schemaless_reader(buf, v1, v2))
# {'orderId': 'o-101', 'amount': 150000, 'currency': 'BDT'} default filled in
Remove "default": "BDT" from v2 and run it again: the read fails, because the new reader has nothing to put in currency. That is the backward check a registry would have run for you.
#Summary
Key takeaways
- Schema evolution = changing a data format while old and new code and old and new data coexist: rolling deploys, independent teams, rollbacks, stored data and old clients make that the normal case, not the exception.
- Backward: new reader reads old data (deploy consumers first). Forward: old reader reads new data (deploy producers first). Full: both (any order). Transitive: against every earlier version, needed for anything replayed or stored.
- Additive changes with defaults are the safe default. A rename is a remove plus an add; do it with expand → migrate → contract. Type changes are breaks; add a new field instead.
- Protobuf: never change or reuse a field number,
reserveddeleted numbers and names, adding fields is safe, unknown fields are kept, few type changes are safe, handle unknown enum values, norequired. - Avro: fields resolved by name; added fields need defaults, removed fields should have had them; renames via aliases (backward only); optional = union with
nullfirst; only listed type promotions. - JSON: be a tolerant reader; watch strict deserializers and closed JSON Schemas; never store money as a float.
- A schema registry checks every new version in CI and puts magic byte + 4-byte schema ID in each record; data never passes through it. Event stores add versions and upcasters; APIs add versions and deprecation headers; databases use expand and contract; contract tests catch what types alone miss.
Answer on your own first, then read the explanation, even when you got it right.
Self-checkQuestion
Which description of schema evolution matches this lesson?
Self-checkQuestion
A schema change is called backward compatible. What exactly is guaranteed?
Self-checkQuestion
Old Billing code receives an event with a field it has never heard of, skips it and processes the rest. Which property is this?
Self-checkQuestion
Why is changing a message format harder in a distributed system than in a single program?
Self-checkQuestion
v2 of the Order service wrote orders with a new currency field. A bug forces a rollback to v1, which reads those orders, updates the status and saves them. Currency disappears from those rows. What was missing?
Self-checkQuestion
A subject is in BACKWARD mode and a new version that removes a field has just been accepted. Which deploy order is safe?
Self-checkQuestion
On Monday the Order team shipped a schema that drops couponCode (BACKWARD accepted it). Shipping still runs v1, whose schema needs couponCode with no default. Shipping is now stuck at one offset with deserialization errors. What is the right recovery?
Self-checkQuestion
A developer deletes coupon_code = 3 from a .proto without reserving it. A year later another developer adds string shipping_note = 3. Old events in a long-retention topic are replayed by the new code. What happens?
Self-checkQuestion
You must remove a field from a widely used Protobuf message. What is the correct way?
Self-checkQuestion
A team renames a Protobuf field from amount to total, keeping number 2. Binary gRPC clients are fine. What can still break?
Self-checkQuestion
Which two Protobuf changes are safe for existing data and existing readers?
Choose 2
Self-checkQuestion
In Avro, v2 adds currency without a default to a subject in BACKWARD mode. What does the registry do, and why?
Self-checkQuestion
An Avro field is declared as type [“string”, “null”] with default null. What happens?
Self-checkQuestion
A v2 Avro reader schema renames amount to total and adds aliases [amount]. Which statement is correct?
Self-checkQuestion
Which two type changes can an Avro reader resolve automatically from data written with the old type?
Choose 2
Self-checkQuestion
A plain Jackson object mapper in a Java consumer starts throwing as soon as the producer adds a harmless optional field. Why?
Self-checkQuestion
An event’s JSON Schema has additionalProperties set to false. The producer adds an optional field. What happens to consumers that validate with the old schema?
Self-checkQuestion
v1 has amount as double, v2 removes amount, v3 adds amount back as string with default “0”. The registry is in BACKWARD (not transitive) mode. A new consumer with v3 replays the topic from offset 0. What happens?
Self-checkQuestion
Which two kinds of data most need a transitive compatibility mode?
Choose 2
Self-checkQuestion
A Kafka record produced with a Confluent serializer starts with the bytes 00 00 00 00 2A. What do they mean?
Self-checkQuestion
The notes draw Producer → Schema Registry → Validate → Kafka → Consumer → Schema Registry. What is wrong with that picture?
Self-checkQuestion
The schema registry is unreachable for ten minutes. Which effect do you expect?
Self-checkQuestion
A consumer gets “schema not found” (error 40403) for ID 42, although the producer works fine. What is the most likely cause?
Self-checkQuestion
In an event-sourced system, what is an upcaster?
Self-checkQuestion
A proto3 producer adds a new value 4 = bkash to a payment method enum. An old consumer receives value 4. What is the real risk?
Self-checkQuestion
The registry accepts every schema change, yet Billing broke after a field it used was renamed. Which tool would most directly have caught this before deploy?
Self-checkQuestion
The notes’ Avro example stores amount as a double. Why is that a problem, and what should you use?
Self-checkQuestion
You need to rename the column amount to total in a busy table while rolling deploys keep old and new code running. Which plan is safe?
Self-checkQuestion
Which two situations break even though “only a field was added”?
Choose 2
Self-checkQuestion
A public REST API must change the type of a response field. Which approach follows good practice?
Self-checkQuestion
Which two changes does FULL compatibility allow for an Avro schema?
Choose 2
Self-checkQuestion
Your mobile app has millions of installs, and some users never update. Which statement about its API responses is correct?