Siddhant Deval
Siddhant Deval
backend16 min read

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.

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.

Architectural Note

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

TYPESCRIPT
// ❌ JSON without schema — producer can change shape at any time
// Producer v1 publishes:
await producer.send({ topic: 'payments', messages: [{
  value: JSON.stringify({ paymentId: 'P1', amount: 9900 })
}]})

// Producer v2 renames the field — no compilation error, no runtime error
await producer.send({ topic: 'payments', messages: [{
  value: JSON.stringify({ paymentId: 'P1', amountCents: 9900 })  // 'amount' is gone
}]})

// Consumer reads from offset 0 — sees both shapes in the same topic
// It has no way to know which messages use v1 and which use v2 schema
const consumer = kafka.consumer({ groupId: 'analytics' })
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value!.toString())
    const amount = event.amount   // undefined for all v2 messages — silent NaN corruption
    await analyticsDb.insert({ paymentId: event.paymentId, revenue: amount })
    // analytics table silently accumulates NaN values for all post-deploy payments
  }
})
Performance / Safety Warning

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

TYPESCRIPT
// payments.avsc — Avro schema definition
const paymentSchema = {
  type:      'record',
  name:      'PaymentProcessed',
  namespace: 'com.company.payments',
  fields: [
    { name: 'paymentId',   type: 'string' },
    { name: 'amountCents', type: 'long' },
    { name: 'currency',    type: 'string' },
    { name: 'customerId',  type: 'string' },
    { name: 'processedAt', type: { type: 'long', logicalType: 'timestamp-millis' } },
    // ✅ New optional field with default — BACKWARD compatible
    { name: 'metadata',    type: ['null', 'string'], default: null },
  ]
}

// Producer: register + serialize
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry'

const registry = new SchemaRegistry({ host: 'http://schema-registry:8081' })

const { id } = await registry.register({
  type:   SchemaType.AVRO,
  schema: JSON.stringify(paymentSchema),
}, { subject: 'payments-value' })

const encodedValue = await registry.encode(id, {
  paymentId:   'P-42',
  amountCents: 9900,
  currency:    'USD',
  customerId:  'C-99',
  processedAt: Date.now(),
  metadata:    null,
})

await producer.send({
  topic:    'payments',
  messages: [{ key: 'P-42', value: encodedValue }]
})
TYPESCRIPT
// Consumer: decode using schema registry (writer schema auto-resolved)
await consumer.run({
  eachMessage: async ({ message }) => {
    // Registry fetches writer schema by ID from the message header
    // Applies Avro resolution rules against the consumer's reader schema
    const event = await registry.decode(message.value!)
    // event is typed as PaymentProcessed — fully deserialized
    await analyticsDb.insert({
      paymentId: event.paymentId,
      revenue:   event.amountCents,
    })
  }
})

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.

TYPESCRIPT
// ✅ BACKWARD compatible change — new field with default value
// Old schema:
{ name: 'paymentId', type: 'string' },
{ name: 'amountCents', type: 'long' },

// New schema (BACKWARD compatible):
{ name: 'paymentId',   type: 'string' },
{ name: 'amountCents', type: 'long' },
{ name: 'metadata',    type: ['null', 'string'], default: null },  // ← has default
// New consumer reading old message: metadata field gets its default (null) ✅
// Old consumer reading new message: unknown field 'metadata' is ignored ❌
TYPESCRIPT
// ❌ BACKWARD breaking change — removing a required field
// Old schema has: { name: 'customerId', type: 'string' }
// New schema removes it — new consumer reading old message cannot find a value for absent field
// (If the field had no default in the old schema, this is an error)

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.

TYPESCRIPT
// ✅ FORWARD compatible change — new field with default in NEW schema
// Old consumer reading new message: sees unknown field 'metadata', ignores it ✅

// ❌ FORWARD breaking change — removing a field the old consumer expects
// Old consumer expects 'customerId' — new schema does not have it
// Avro resolution: field not in writer schema, not in reader schema = error

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:

BASH
# Set compatibility on the subject in Schema Registry
curl -X PUT http://schema-registry:8081/config/payments-value \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d '{"compatibility": "FULL_TRANSITIVE"}'
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
Crucial Requirement

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)
Pro Tip & Optimization

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:

YAML
# .github/workflows/schema-check.yml
name: Schema Compatibility Check

on: [pull_request]

jobs:
  schema-check:
    runs-on: ubuntu-latest
    services:
      schema-registry:
        image: confluentinc/cp-schema-registry:7.6.0
        env:
          SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: 'localhost:9092'
          SCHEMA_REGISTRY_HOST_NAME: schema-registry
        ports: ['8081:8081']

    steps:
      - uses: actions/checkout@v4

      - name: Check schema compatibility
        run: |
          # Attempt to register changed schemas against the registry
          # Registry enforces BACKWARD_TRANSITIVE — incompatible changes fail here
          node scripts/check-schema-compatibility.mjs
        env:
          SCHEMA_REGISTRY_URL: http://localhost:8081
TYPESCRIPT
// scripts/check-schema-compatibility.mjs
import { SchemaRegistry, SchemaType } from '@kafkajs/confluent-schema-registry'
import { readdirSync, readFileSync } from 'fs'

const registry = new SchemaRegistry({ host: process.env.SCHEMA_REGISTRY_URL })

const schemas = readdirSync('schemas/').filter(f => f.endsWith('.avsc'))

for (const file of schemas) {
  const schema = readFileSync(`schemas/${file}`, 'utf-8')
  const subject = file.replace('.avsc', '-value')

  // testCompatibility throws if the schema is incompatible
  const result = await registry.testCompatibility(subject, {
    type: SchemaType.AVRO, schema
  })

  if (!result) {
    console.error(`❌ Schema ${subject} is incompatible with registered versions`)
    process.exit(1)
  }
  console.log(`✅ ${subject} is compatible`)
}
Mental Model Check

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_id propagation 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.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Avro#Schema Registry#Schema Evolution#Kafka#CloudEvents#Protobuf#Backend#Distributed Systems
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.