Schema Evolution: Avro, Schema Registry, and Compatibility Contracts
A schema is a distributed contract between producers and consumers. Breaking changes deployed to producers instantly corrupt consumers reading the same topic at different offsets — hours after the deploy, silently. This article implements Avro serialization with Confluent Schema Registry, explains BACKWARD, FORWARD, and FULL compatibility modes, and enforces schema evolution as a CI merge gate.
Distributed Messaging Systems
Schema Evolution: Avro, Schema Registry, and Compatibility Contracts
The payments team renames the amount field to amountCents in their PaymentProcessed event — a routine cleanup. They deploy the producer on a Tuesday morning. By Tuesday afternoon, the analytics consumer, which was not redeployed, starts throwing AvroRuntimeException: Expected field 'amount' not found on every message. The consumer's committed offset means it will replay this failure for every message published since the producer deployed. Two downstream read models are now stale. The root cause is not the rename — it is the absence of a schema contract that would have caught this before the deploy.
A schema is not documentation. It is a distributed contract between producers and consumers, enforced at the serialization boundary. Breaking that contract silently in production is not a schema problem — it is a deployment process problem.
Series positioning: This is Part 9 of Distributed Messaging Systems. It is the first article in the series to address the contract between producer and consumer explicitly. The schema registry pattern applies to Kafka-based systems and any event-driven architecture where producers and consumers deploy independently. The follow-up GraphQL Schema Evolution: Managing Breaking Changes covers the same problem in API contracts.
1. Why JSON is Not a Schema
1.1 The Silent Corruption Problem
JSON schema mismatch does not throw at runtime. event.amount returns undefined when the field is absent — downstream arithmetic silently produces NaN, null, or 0. This class of bug manifests in dashboards and reports, not in error logs, making it among the hardest to diagnose in production.
1.2 The Wire Format Problem
JSON is verbose. Every message carries full field names as strings. For high-throughput topics (millions of messages per day), the savings from binary encoding are significant:
| Format | 100-field event size | Parse time | Schema enforcement |
|---|---|---|---|
| JSON | ~2,000 bytes | Slow (string scan) | None |
| Protobuf | ~200 bytes | Fast (binary decode) | Compile-time |
| Avro | ~150 bytes | Fast (binary decode) | Registry-enforced |
2. Avro + Confluent Schema Registry
2.1 How It Works
The Confluent Schema Registry maintains a versioned history of every schema for every Kafka topic subject. Producers register a schema on first use and receive a schema ID. They embed this 4-byte ID in the message payload (after a magic byte). Consumers extract the ID, fetch the schema from the registry, and use it to deserialize.
2.2 Avro Schema and TypeScript Integration
3. The Three Compatibility Modes
3.1 BACKWARD: New Reads Old
BACKWARD compatibility means a consumer using the new schema can deserialize messages written with the previous schema. This is the minimum requirement for rolling deployments — old producers continue writing while new consumers deploy.
3.2 FORWARD: Old Reads New
FORWARD compatibility means an old consumer can deserialize messages written with the new schema. This is required for rolling deployments where new producers deploy while old consumers are still running.
3.3 FULL and FULL_TRANSITIVE: The Production Default
FULL compatibility = BACKWARD + FORWARD simultaneously. Both old and new versions of consumers can read both old and new messages. FULL_TRANSITIVE checks against ALL previous versions, not just the immediately previous one:
| Mode | New reads old | Old reads new | Checks against |
|---|---|---|---|
BACKWARD |
✅ | ❌ | Previous version only |
BACKWARD_TRANSITIVE |
✅ | ❌ | All previous versions |
FORWARD |
❌ | ✅ | Previous version only |
FORWARD_TRANSITIVE |
❌ | ✅ | All previous versions |
FULL |
✅ | ✅ | Previous version only |
FULL_TRANSITIVE |
✅ | ✅ | All previous versions |
NONE |
— | — | No compatibility check |
Use BACKWARD_TRANSITIVE or FULL_TRANSITIVE in production — not BACKWARD. The non-transitive modes only check against the immediately preceding schema version. If you skip a version (e.g., a hotfix rolls back v3 and goes straight from v2 to v4), BACKWARD would pass v4 against v3, but v4 might be incompatible with v2 which is still in the log. Transitive modes check all historical versions.
4. Safe Schema Evolution Rules
| Change type | BACKWARD? | FORWARD? | FULL? |
|---|---|---|---|
| Add field with default | ✅ | ✅ | ✅ |
| Remove field with default | ✅ | ✅ | ✅ |
| Add field without default | ❌ | ✅ | ❌ |
| Remove required field | ✅ | ❌ | ❌ |
| Rename field | ❌ | ❌ | ❌ |
| Change field type | ❌ | ❌ | ❌ |
| Widen type (int → long) | ✅ | ❌ | ❌ |
Never rename a field. Avro does not support renaming — the only backward-compatible approach is: add the new field with default, deprecate the old field in documentation, migrate consumers to the new field, then remove the old field in a separate schema version after all consumers have migrated. This takes at least two deploys but preserves zero-downtime compatibility throughout.
5. Schema Registry as a CI Merge Gate
The most important architectural decision is making schema evolution failures a CI check, not a production incident:
Think of the Schema Registry as the database migration system for your event stream. Just as you would not merge a migration that drops a column used by deployed code, you should not merge a schema change that breaks deployed consumers. The registry enforces this automatically — treat a compatibility failure the same way you treat a failing test.
Summary
| Concept | Rule |
|---|---|
| BACKWARD compatibility | BACKWARD compatibility means new schema can read old data — this is the minimum required for rolling deployments where old consumers still run while new producers deploy. |
| BACKWARD_TRANSITIVE | BACKWARD_TRANSITIVE is the production-safe default: it checks compatibility against ALL previous schema versions, not just the last one. |
| Schema Registry as gate | Schema Registry as a merge gate eliminates the class of bugs where a schema change silently corrupts consumer parsing hours after a producer deploy. |
What's Next
Part 10: Observability and Dead-Letter Queue Patterns closes the production operations loop: how to instrument
trace_idpropagation across async message boundaries, what metrics to alert on (lag, DLQ depth, consumer restart rate), and how to build a DLQ replay pipeline that lets you re-process failed messages after fixing the underlying bug.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.