How-to › Integrate beyond REST

How to evolve an Avro event schema without breaking consumers#

Register an Avro schema for a topic, set BACKWARD compatibility, and ship a field with a default that consumers you have not redeployed keep reading.

Audience
API producer
Level
intermediate
Topic
Consume and publish events
Languages
TypeScript and JavaScript
Verified

You add a field to the order event, deploy the producer, and the fulfillment consumer starts throwing on every record. Its unit tests passed, because they encoded and decoded with the same schema. Nothing compared the changed schema with the one the consumer was built against, and the broker does not care what the bytes mean. The topic holds three days of records it cannot read.

What you get

You will end up with a BACKWARD subject, a producer that adds a defaulted field, a consumer on the previous schema still reading, and proof that the registry refuses the breaking change. This is for you if other teams consume your topic.

Short answer

Register the topic’s Avro schema in a schema registry under a BACKWARD-compatible subject, then add fields only with a default, so a consumer holding the previous schema still resolves every record. Each record carries a zero byte, the schema id, and the Avro bytes; the consumer fetches the writer schema by id and resolves it against its own. The registry refuses a field without a default at registration, which is where the break should surface.

You will need

Node 22 or later, a Kafka topic whose records are Avro, and a schema registry that speaks the Confluent REST API. Verified 2026-10-21 against Node 22.22.0, @kafkajs/confluent-schema-registry 4.1.0, and avsc 5.7.9, installed from npm in the sample directory. The sample runs no broker and no real registry. An in-process stand-in answers the registry routes the client calls, with the compatibility check done by Avro’s resolution rules as avsc implements them. The record values are the buffers a producer would send.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Apicurio Registry with JSON SchemaYou run your own registry and want one place for Avro, JSON Schema, Protobuf, OpenAPI, and AsyncAPI artifactsA service you operate, and JSON Schema compatibility judged on a constraint model that is harder to reason about than Avro’s field rulesYour clients are the Confluent serializers and you would rather not test them against a second API
AsyncAPI documents checked in CIThe contract is reviewed in pull requests and no runtime dependency is wantedNothing at run time compares the bytes with the document, so a producer can ship what the document forbidsSeveral producers, or consumers you do not deploy yourself
AWS Glue Schema RegistryProducers and consumers on AWS, with MSK, and no registry to runJSON Schema only up to draft-07, its own wire format and serializer libraries, and mode names that differ from the Confluent onesClients outside the AWS SDKs, or a wire format other tools already read
Confluent Schema Registry with AvroKafka with clients in several languages, all of which read the same five-byte prefixA service with its own storage topic to run, subject names that are hard to change later, and a default check against the latest version onlyJSON on the wire that people read by hand, or no Kafka at all
Version field in the payload, no registryOne producer, a few consumers you deploy, and JSON payloadsA version switch in every consumer, with no check that refuses an incompatible change before it shipsMore than a handful of consumers, or any consumer you do not deploy

The registry buys one thing: a change that would break a reader is refused before a producer ships it. The Confluent registry is the one most clients already speak, at the cost of a service and a subject layout you choose once. Apicurio takes more formats behind a compatible API, for a second service to run. AsyncAPI in CI adds no runtime dependency, and for the same reason it never sees the bytes. A version field costs nothing at all and moves the whole burden into every consumer.

Name the subject and register the first schema

A subject is the name the registry keeps versions under, and the compatibility check runs within a subject. The Confluent serializers derive it from the topic, the record, or both, by a subject name strategy. The client here takes the subject as a string, so the sample makes the choice in one module.

export const strategies = {
  // One schema per topic. The default, and right until a topic carries two event types.
  TopicNameStrategy: (topic, schema, { isKey = false } = {}) => `${topic}-${isKey ? 'key' : 'value'}`,
  // One schema per record type, whatever topic it travels on.
  RecordNameStrategy: (topic, schema) => fullName(schema),
  // One schema per record type per topic.
  TopicRecordNameStrategy: (topic, schema) => `${topic}-${fullName(schema)}`,
}

The producer registers the schema once and gets an id. encode then writes the record value in the registry’s wire format: a magic byte of zero, the schema id as a four-byte big-endian integer, and the Avro binary.1

const subject = strategies.TopicNameStrategy(topic, orderPlacedV1)
const { id } = await registry.register({ type: SchemaType.AVRO, schema: JSON.stringify(orderPlacedV1) }, { subject })
console.log(`\nregistered shop.orders.OrderPlaced under ${subject}: id ${id}, compatibility ${local.levelOf(subject)}`)

// encode() writes the wire format: one zero byte, the schema id as a big-endian int32, then the
// Avro binary. This buffer is what goes in the Kafka record's value.
const value = await registry.encode(id, { orderId: 'ord_1', customerId: 'cus_9', totalCents: 4200 })
node register.mjs
TopicNameStrategy        orders-value
RecordNameStrategy       shop.orders.OrderPlaced
TopicRecordNameStrategy  orders-shop.orders.OrderPlaced

registered shop.orders.OrderPlaced under orders-value: id 1, compatibility BACKWARD
record value: 19 bytes
  byte 0      magic byte  0
  bytes 1-4   schema id   1  (0 0 0 1)
  bytes 5-18  avro        0a6f72645f310a6375735f39d041

decoded by a fresh consumer: {"orderId":"ord_1","customerId":"cus_9","totalCents":4200}
registry requests:
  GET /config/orders-value
  POST /subjects/orders-value/versions
  PUT /config/orders-value
  GET /schemas/ids/1

Fourteen of the nineteen bytes are the order. The other five are what lets a consumer that has never seen this schema read it: it takes the id from the prefix, fetches the writer schema with GET /schemas/ids/1 once, and caches it. The PUT /config is the client setting its default level, BACKWARD, which is also the registry’s default, on a subject that had none.

Pick the strategy before the first producer ships, because changing a subject later means re-registering history. Topic name is right while a topic carries one event type; several types need a record-based strategy, as the last section shows.

Add the field under BACKWARD

BACKWARD means that a consumer using the new schema can read data produced with the last schema, in the words of the Confluent documentation. The check is Avro’s own schema resolution. Fields are matched by name, and a writer’s field the reader lacks is skipped. A reader’s field the writer lacks is filled from the reader’s default or, with no default, is an error. So a field arrives with a default, and a consumer on the previous schema keeps resolving every record.

// The change: a field with a default, registered under BACKWARD. The registry checks that the
// new schema can read what v1 wrote, and only then hands out an id.
const { id: id2 } = await producer.register(asAvro(orderPlacedV2), { subject, compatibility: 'BACKWARD' })
const newBytes = await producer.encode(id2, { orderId: 'ord_2', customerId: 'cus_9', totalCents: 1500, currency: 'USD' })
console.log(`v1 is id ${id1}, v2 is id ${id2}, subject ${subject} is ${local.levelOf(subject)}`)

// The consumer that has not been redeployed still holds v1 as its reader schema. Resolution
// matches fields by name, so the field it does not know is skipped.
const oldConsumer = new SchemaRegistry({ host: local.url })
const readByOld = await oldConsumer.decode(newBytes, { [SchemaType.AVRO]: { readerSchema: orderPlacedV1 } })
console.log(`old consumer, reader v1, new bytes: ${JSON.stringify(readByOld)}`)

// The redeployed consumer holds v2 and rewinds to the start of the topic. The field v1 never
// wrote is filled from the default in its own schema.
const newConsumer = new SchemaRegistry({ host: local.url })
const readByNew = await newConsumer.decode(oldBytes, { [SchemaType.AVRO]: { readerSchema: orderPlacedV2 } })
console.log(`new consumer, reader v2, old bytes: ${JSON.stringify(readByNew)}`)
node evolve.mjs
v1 is id 1, v2 is id 2, subject orders-value is BACKWARD
old consumer, reader v1, new bytes: {"orderId":"ord_2","customerId":"cus_9","totalCents":1500}
new consumer, reader v2, old bytes: {"orderId":"ord_1","customerId":"cus_9","totalCents":4200,"currency":"EUR"}

register v3 (channel, no default): HTTP 409
  Confluent_Schema_Registry - Schema being registered is incompatible with an earlier schema for subject "orders-value", details: no matching field for default-less shop.orders.OrderPlaced.channel
versions of orders-value: [{"id":1,"version":1},{"id":2,"version":2}]

The consumer on v1 reads a v2 record and never sees currency. The consumer on v2 reads a v1 record and gets EUR, the default, for a field the producer never wrote.2 The reader schema is passed to decode, and the writer schema comes from the registry by id, which is the whole mechanism. Then v3 adds channel with no default, and the registration is answered with 409, the status the REST API documents for an incompatible schema, before any producer can use it. The subject stays at two versions.

Each format has its own rules. Avro’s are the resolution rules quoted earlier, which is why the check is a short list. The Confluent compatibility guide calls the Protobuf and JSON Schema rules more nuanced, and recommends BACKWARD_TRANSITIVE for Protobuf because adding a message type is not forward compatible.

The same two calls exist in the other clients. In confluent-kafka-python the AvroDeserializer takes the reader schema as schema_str, and in Go the franz-go sr package pairs a Serde with a ConfluentHeader that produces the same five-byte prefix.

Check it worked

The test that matters registers v2 and then v3 under BACKWARD, and asserts that the second attempt is refused with a 409 that names the field.

test('under BACKWARD a field with a default registers and a field without one is refused', async () => {
  const registry = new SchemaRegistry({ host: local.url })
  const { id: id1 } = await registry.register(asAvro(orderPlacedV1), { subject: 'backward-value' })
  const { id: id2 } = await registry.register(asAvro(orderPlacedV2), { subject: 'backward-value', compatibility: 'BACKWARD' })
  assert.notEqual(id1, id2)
  await assert.rejects(registry.register(asAvro(orderPlacedV3NoDefault), { subject: 'backward-value' }), (err) => {
    assert.equal(err.status, 409)
    assert.match(err.message, /incompatible with an earlier schema/)
    assert.match(err.message, /default-less shop\.orders\.OrderPlaced\.channel/)
    return true
  })
  assert.deepEqual(local.versions('backward-value').map((v) => v.version), [1, 2])
})
node --test evolve.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 304.828737

Seven tests pass. The others pin the five-byte prefix, both reader schemas, and the subject names each strategy produces. One pins the difference between the levels: removing a field passes BACKWARD and fails FORWARD and FULL, since a consumer on the previous schema could not fill it. Another pins the transitive case.

When it goes wrong

The unit tests are green and the registry says no. A test that encodes and decodes with one schema proves that schema agrees with itself, and the registry compares it with what is already registered. The compatibility endpoint answers the same question without registering anything.

node pitfall.mjs
v3 round trip in a unit test:  {"orderId":"ord_3","customerId":"cus_9","totalCents":10,"currency":"EUR","channel":"web"}
v3 against orders-value:       {"is_compatible":false,"messages":["no matching field for default-less shop.orders.OrderPlaced.channel"]}

avsc fromBuffer on the framed value: throws "trailing data"
avsc fromBuffer after 5 bytes:      {"orderId":"ord_1","customerId":"cus_9","totalCents":4200}

OrderCancelled under orders-value: HTTP 409
  Confluent_Schema_Registry - Schema being registered is incompatible with an earlier schema for subject "orders-value", details: no alias found for shop.orders.OrderPlaced
OrderCancelled under shop.orders.OrderCancelled: id 2

Run the compatibility check in CI with verbose=true, so a pull request that adds a field without a default fails there rather than in a deploy.

A plain Avro decoder throws on every record. It was handed the framed value, so it read the magic byte as the first field. Here the result is trailing data; another schema could yield wrong values and no error. Strip the five bytes, or use a registry-aware deserializer, which reads the id and fetches the schema.

A second event type on the topic is refused as incompatible. Under the topic name strategy, both records share the subject orders-value, and OrderCancelled cannot read what OrderPlaced wrote. The record name strategy gives each type its own subject, and the registration goes through.

When not to do this

Do not add a registry to a topic with one producer and one consumer in one repository. A shared schema file and the same compatibility check run in that repository’s tests gives the same protection without a service to run.

Do not set a subject to NONE to get a deploy out. The check that refused the change is the one thing the registry does, and a consumer that cannot read the topic is the outage you bought the registry to prevent.

Do not choose FORWARD because the producer team wants to remove fields. BACKWARD is the Confluent default so that a consumer can be rewound to the start of the topic, and FORWARD gives up that guarantee.3

Do not read BACKWARD as a check against the whole history. It compares the candidate with the latest version only, so a version that drops a default the previous version added passes, though older records become unreadable. BACKWARD_TRANSITIVE checks every earlier version, at the cost of refusing changes the registry would otherwise allow.4

Last verified

Verified 2026-10-21 against Node 22.22.0, @kafkajs/confluent-schema-registry 4.1.0, and avsc 5.7.9. Every output block is what the command preceding it printed. The registry in the sample is an in-process stand-in for the routes the client calls, with compatibility judged by the schema resolution in avsc. Its 409 is the status the API reference documents for an incompatible schema, with a message of the stand-in’s own. Confluent Schema Registry, Apicurio Registry, and the Glue registry were not run; their behavior is taken from their documentation. No broker was involved, so the record values are the buffers a producer would send.

Footnotes

  1. The wire format table calls byte 0 the version byte and gives its value as “0 when using the schema ID (the default),” a parenthesis that does some work. The same page documents a second layout, in which the version byte is 1 and bytes 1 to 16 carry a schema GUID. So the magic byte is a version number that has acquired a second value, and a decoder that checks for zero is checking something after all. ↩︎ Back to text

  2. Avro’s specification is careful about what a default is for. A default is “only used when reading instances that lack the field,” and its presence “does not make the field optional at encoding time,” as the specification puts it. A producer on v2 still has to supply currency on every record. The default exists for the reader, and for the records written before anyone thought of the field. ↩︎ Back to text

  3. The Confluent compatibility page gives the reason BACKWARD is preferred for Kafka: “so that you can rewind consumers to the beginning of the topic,” a rewind FORWARD does not guarantee. The same page adds that FORWARD is harder to work with, because “you need to anticipate all future changes,” which is a fair description of most forward planning. ↩︎ Back to text

  4. The transitive levels have two names. Confluent and Apicurio write BACKWARD_TRANSITIVE, and AWS Glue writes BACKWARD_ALL for a check it defines the same way, against all previous schema versions. Apicurio’s documentation supplies the case the plain level misses: a third version that passes the BACKWARD check against the second and fails the BACKWARD_TRANSITIVE check against the first. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.