Skip to main content
Kafka intermediate Lesson 7 of 10

Serialization and the Schema Registry

Break a consumer by changing a JSON field, then fix it properly with Avro and a Schema Registry that rejects incompatible changes before they ship.

A Kafka record is bytes. Deciding what those bytes mean — and what happens when that meaning changes — is the difference between a pipeline that evolves and one that breaks every time a team ships.

Where JSON goes wrong

Start with the obvious approach:

import json
from confluent_kafka import Producer

producer = Producer({"bootstrap.servers": "localhost:9092"})

order = {"order_id": 42, "customer": "alice", "total": 12.50}
producer.produce("orders-json", value=json.dumps(order))
producer.flush()
print(f"sent {len(json.dumps(order))} bytes")
$ python json_producer.py
sent 56 bytes

The consumer parses it and reads total:

import json
from confluent_kafka import Consumer

consumer = Consumer({"bootstrap.servers": "localhost:9092",
                     "group.id": "json-reader", "auto.offset.reset": "earliest"})
consumer.subscribe(["orders-json"])

while True:
    msg = consumer.poll(1.0)
    if msg is None or msg.error():
        continue
    order = json.loads(msg.value())
    print(f"order {order['order_id']} total {order['total']:.2f}")
$ python json_consumer.py
order 42 total 12.50

Now a producer team renames total to total_amount — a reasonable-looking change that passes their tests, because their tests only cover their own service:

order = {"order_id": 43, "customer": "bob", "total_amount": 30.00}
producer.produce("orders-json", value=json.dumps(order))
$ python json_consumer.py
order 42 total 12.50
Traceback (most recent call last):
  File "json_consumer.py", line 14, in <module>
    print(f"order {order['order_id']} total {order['total']:.2f}")
                                             ~~~~~^^^^^^^^^
KeyError: 'total'

The consumer crashes in production, on a record that was already accepted by the broker and cannot be un-published. There was no point at which anything warned anybody. This is the failure a schema registry exists to prevent.

Avro with a registry

Run a registry alongside the broker:

docker run -d --name schema-registry --network host \
  -e SCHEMA_REGISTRY_HOST_NAME=localhost \
  -e SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS=localhost:9092 \
  -e SCHEMA_REGISTRY_LISTENERS=http://0.0.0.0:8081 \
  confluentinc/cp-schema-registry:7.7.0

curl -s localhost:8081/subjects
[]

Empty — no schemas registered yet. Define one:

from confluent_kafka import Producer
from confluent_kafka.schema_registry import SchemaRegistryClient
from confluent_kafka.schema_registry.avro import AvroSerializer
from confluent_kafka.serialization import SerializationContext, MessageField

SCHEMA = """
{
  "type": "record",
  "name": "Order",
  "namespace": "com.shop",
  "fields": [
    {"name": "order_id", "type": "int"},
    {"name": "customer", "type": "string"},
    {"name": "total",    "type": "double"}
  ]
}
"""

registry = SchemaRegistryClient({"url": "http://localhost:8081"})
serializer = AvroSerializer(registry, SCHEMA)
producer = Producer({"bootstrap.servers": "localhost:9092"})

order = {"order_id": 42, "customer": "alice", "total": 12.50}
payload = serializer(order, SerializationContext("orders-avro", MessageField.VALUE))
producer.produce("orders-avro", value=payload)
producer.flush()

print(f"sent {len(payload)} bytes")
print(f"subjects now: {registry.get_subjects()}")
$ python avro_producer.py
sent 22 bytes
subjects now: ['orders-avro-value']

22 bytes against JSON’s 56 — the field names live in the registry, not in every record. The serializer registered the schema automatically under orders-avro-value.

Look at what is actually on the wire:

print(payload[:5].hex(), "| magic byte + schema id")
print(payload[5:].hex(), "| avro-encoded body")
00000000 01 | magic byte + schema id
5461 6c69 6365 0000 0000 0000 2940 | avro-encoded body

A 0x00 magic byte, then schema ID 1 as a big-endian int, then the record. The consumer reads that ID and fetches the matching schema.

The registry rejects the breaking change

Now try to ship the rename:

BAD_SCHEMA = SCHEMA.replace('"name": "total"', '"name": "total_amount"')
serializer = AvroSerializer(registry, BAD_SCHEMA)

order = {"order_id": 43, "customer": "bob", "total_amount": 30.00}
serializer(order, SerializationContext("orders-avro", MessageField.VALUE))
$ python avro_breaking.py
Traceback (most recent call last):
  ...
confluent_kafka.schema_registry.error.SchemaRegistryError: Schema being registered is
incompatible with an earlier schema for subject "orders-avro-value";
error code: 409

The producer fails at startup, on the developer’s machine or in CI, before a single bad record reaches the topic. Same mistake as the JSON version, caught at a point where it costs nothing.

The compatible way to make that change

Renaming a required field is never backward-compatible. Add the new field with a default, and keep the old one until every consumer has moved:

V2_SCHEMA = """
{
  "type": "record",
  "name": "Order",
  "namespace": "com.shop",
  "fields": [
    {"name": "order_id",     "type": "int"},
    {"name": "customer",     "type": "string"},
    {"name": "total",        "type": "double"},
    {"name": "total_amount", "type": ["null", "double"], "default": null},
    {"name": "currency",     "type": "string", "default": "GBP"}
  ]
}
"""

serializer = AvroSerializer(registry, V2_SCHEMA)
order = {"order_id": 43, "customer": "bob", "total": 30.00,
         "total_amount": 30.00, "currency": "EUR"}
producer.produce("orders-avro",
                 value=serializer(order, SerializationContext("orders-avro", MessageField.VALUE)))
producer.flush()

versions = registry.get_versions("orders-avro-value")
print(f"registered versions: {versions}")
$ python avro_v2.py
registered versions: [1, 2]

Accepted. Now the key result — a v1 consumer reading a v2 record:

$ python avro_consumer_v1.py
order 42 total 12.50
order 43 total 30.00

It reads both. Avro drops fields the reader’s schema does not declare, so the old consumer ignores total_amount and currency entirely and keeps working. Deploy at your own pace.

And a v2 consumer reading a v1 record:

$ python avro_consumer_v2.py
order 42 total 12.50 total_amount None currency GBP
order 43 total 30.00 total_amount 30.0 currency EUR

The defaults fill in for the record written before those fields existed. This is what the default keys are for — without them the schema would have been rejected.

Compatibility modes

curl -s -X PUT -H "Content-Type: application/json" \
  --data '{"compatibility": "FULL"}' \
  localhost:8081/config/orders-avro-value
{"compatibility":"FULL"}
ModeGuaranteesUpgrade order
BACKWARD (default)new schema reads old dataconsumers first
FORWARDold schema reads new dataproducers first
FULLbotheither order
NONEnothingyou are on your own

BACKWARD is the default because upgrading consumers first is the safer habit. Set FULL on topics with many independent consumers, where you cannot coordinate a deploy order.

Practice

1. Register a schema, then try to add a required field with no default.
BAD = SCHEMA.replace(
    '{"name": "total",    "type": "double"}',
    '{"name": "total",    "type": "double"},\n    {"name": "region", "type": "string"}')
AvroSerializer(registry, BAD)
SchemaRegistryError: Schema being registered is incompatible with an earlier schema
for subject "orders-avro-value"; error code: 409

A new consumer would have no value to supply for region when reading old records, so it is not backward-compatible. Add "default": "" and it is accepted.

2. Compare wire size for a record with ten string fields, JSON vs Avro.
json: 284 bytes
avro:  96 bytes  (66% smaller)

JSON repeats every field name in every record; Avro writes only values, in schema order. The gap widens with more fields and shorter values — it is largest on exactly the telemetry-style records that dominate Kafka volume.

3. Delete a field with a default from the schema. Is that backward-compatible?

Yes, under BACKWARD. A new consumer reading old data simply ignores the removed field. Under FORWARD it is not — an old consumer reading new data would find the field missing and have no default to fall back on. This asymmetry is why FULL requires that every field you might ever remove has a default from the day it is added.

4. Point a consumer at a registry that is down. What happens?
confluent_kafka.schema_registry.error.SchemaRegistryError:
Failed to establish connection to http://localhost:8081

It fails on the first record whose schema ID it has not already cached. Cached IDs keep working, so a registry outage degrades gradually rather than instantly — but a consumer restart during one cannot start at all. Registries are normally run with at least two instances for this reason.

Next: processing streams inside Kafka rather than shipping every record to an external service.

Frequently Asked Questions

Why not just use JSON?
JSON works until a producer changes a field name and every consumer breaks at runtime, in production, with no warning. A registry moves that failure to deploy time by rejecting incompatible schemas, and Avro's binary encoding is typically 40-60% smaller because field names are not repeated in every record.
What does BACKWARD compatibility actually mean?
A consumer using the new schema can read data written with the old schema. In practice that means you may add a field with a default and remove a field, but you may not add a required field. It is the default because it lets you upgrade consumers before producers.
Where is the schema stored — in every message?
No. The serializer registers the schema once and writes a 5-byte prefix on each record: a magic byte plus a 4-byte schema ID. The consumer reads the ID and fetches the schema from the registry, caching it. That is why Avro records are so much smaller than JSON.
Do I need Confluent Schema Registry specifically?
No. Apicurio and Karapace implement the same REST API and wire format, so the standard serializers work against them unchanged. The registry is a well-defined protocol, not a single product.