Build a Schema Registry for Event-Driven Systems


distributed-systems data-engineering api-design

System Design Deep Dive

Schema Registry for Event-Driven Systems

Preventing breaking schema changes from silently corrupting thousands of downstream consumers - without slowing down producer teams.

⏱ 14 min read📐 Advanced🏗️ Event-Driven

Think of a Kafka topic as a shared language between services. The Order Service speaks it when it emits OrderPlaced events; the Analytics Service, Search Indexer, and Audit Logger all listen and parse those same messages. When the language is informal - just JSON with no enforced structure - the system works fine until a developer on the Order team renames customerId to customer_id in a Monday morning commit. By Tuesday, the Analytics pipeline is silently dropping every order because its deserialization code can no longer find the field it expects. The bug is discovered three days later in a quarterly report.

This is the schema contract problem: in an event-driven system, producers and consumers are decoupled in time but tightly coupled in data format. Without a mechanism to enforce that format contracts are honored across releases, any producer can unilaterally break every consumer - and the failure is often invisible until significant data loss has already occurred. The naive solution is team-wide convention and code review, which scales to about 10 engineers and falls apart completely at 100.

A schema registry is the infrastructure solution. It acts as a centralized catalog of every event schema in the system, enforces compatibility rules before a new schema version can be published, and provides consumers with a reliable way to look up the schema for any message they receive. It transforms the schema contract from an informal gentlemen’s agreement into a machine-enforced invariant.

The challenge is that enforcement cannot be so strict that it blocks legitimate producer evolution. A producer team that needs to add a new field to support a new feature should not require a multi-team migration ceremony. The registry must support backward and forward compatibility - the ability for new producers and old consumers (or old producers and new consumers) to coexist on the same topic simultaneously. Getting these compatibility rules right, implementing an efficient wire format, and scaling the registry itself to handle millions of schema lookups per second across multiple regions - these are the hard problems this post addresses.

We need to solve for: enforcing compatibility without blocking legitimate evolution, efficient per-message schema resolution at high throughput, versioning semantics that let producers and consumers evolve independently, and an operational model that does not make the registry a single point of failure for the entire event pipeline.

Requirements and Constraints

Functional Requirements:

  • Register new schemas and new schema versions via a REST API, enforcing configured compatibility mode per subject
  • Look up a schema by its integer ID in under 5ms (p99) for Kafka producer/consumer hot path
  • Support Avro and Protobuf schema formats with full field-level compatibility checking
  • Enforce per-subject compatibility modes: BACKWARD, FORWARD, FULL, NONE
  • Support the concept of a “subject” mapped to Kafka topic names (topic-level and record-level subjects)
  • Provide a compatibility check endpoint so CI/CD pipelines can fail fast before deploying a new producer
  • Store full schema version history with the ability to query any historical version by ID or version number
  • Support soft-deletes of schema versions without breaking consumers that hold references to those IDs

Non-Functional Requirements:

  • Schema lookup latency: p50 under 1ms (cache hit), p99 under 5ms (cache miss, DB fetch)
  • Schema registration throughput: 100 new schema versions per second peak (schema changes are infrequent in practice)
  • Schema ID lookup throughput: 500,000 requests per second across the fleet (producers and consumers combined)
  • Availability: 99.99% uptime - the registry being down must not stop producers from producing if they have cached schema IDs
  • Data durability: zero schema loss - a registered schema must survive any single-node or region failure
  • Support multi-region active-passive with cross-region replication lag under 1 second

Constraints and Assumptions:

  • Schema IDs are immutable once assigned - deleting a schema ID is a soft delete, not a physical delete
  • Compatibility checking applies only to schema evolution within a subject, not across subjects
  • The registry does not validate individual messages against schemas - that happens in producer/consumer libraries
  • Initial scale: 200 subjects, 5,000 total schema versions, 50 Kafka topics
  • Growth: 2 new schema versions per week per team, 30 teams

High-Level Architecture

The system has five major components arranged in two planes: the control plane (schema registration and compatibility checking) and the data plane (schema lookups at message production and consumption time).

Schema registry architecture showing producers, consumers, the registry REST API, schema store, and Kafka topics

The Schema Registry REST API is the entry point for both producers (registering schemas) and consumers (looking up schemas by ID). It fronts all reads and writes and enforces compatibility rules synchronously - a registration request that violates the configured compatibility mode receives a 409 Conflict before the schema is persisted.

The Schema Store is the durable backing store for all schema versions. We use the _schemas Kafka topic as the primary store (enabling log-compacted replication across regions) with a relational database for fast indexed lookups. Every schema is assigned a globally unique monotonically increasing integer ID.

The Compatibility Engine is the heart of the registry. When a new schema version is submitted, it loads the current version from the store, runs the configured compatibility algorithm against the pair, and returns pass or fail. The algorithm varies by schema format (Avro uses field-level union analysis; Protobuf uses field number and type rules).

Producer-side Serializers (the KafkaAvroSerializer or equivalent) integrate with the registry on the write path. Before encoding a message, the serializer looks up the schema ID from its local cache. On a cache miss, it calls the registry POST /subjects/{subject}/versions to register or retrieve the ID, then caches it locally for the process lifetime.

Consumer-side Deserializers integrate on the read path. Each message has a 5-byte prefix containing the magic byte (0x00) and a 4-byte schema ID. The deserializer extracts the ID, looks it up in its local cache, calls the registry on a miss, then decodes the message using the retrieved Avro or Protobuf schema.

Key Insight

The schema ID in the message wire format is what enables consumers to evolve independently. A consumer running schema v1 receives a message encoded with schema v2 - it reads the 4-byte ID, fetches v2 from the registry, and can use Avro’s schema projection to read v2 data using v1’s reader schema, ignoring any new fields it does not understand. Without the registry, there is no way to know which schema was used to write a given message.

The Schema Store and Versioning Model

The schema store is responsible for assigning and persisting schema IDs, storing schema text, and mapping subject names to ordered version sequences. Think of a subject as a named history of one schema’s evolution - like a Git repository for a single type definition.

The wire protocol uses a compact binary prefix to avoid embedding full schemas in every message. Compare the overhead: a typical Avro schema definition is 500-2000 bytes. A message payload averages 200-500 bytes. Embedding the schema in every message would double or triple the data volume in Kafka. The 5-byte prefix approach amortizes schema overhead to essentially zero.

-- Schema Registry data model

-- Global schema ID -> schema text mapping (immutable once created)
CREATE TABLE schemas (
  id SERIAL PRIMARY KEY,
  schema_type VARCHAR(20) NOT NULL CHECK (schema_type IN ('AVRO', 'PROTOBUF', 'JSON')),
  schema_text TEXT NOT NULL,
  schema_hash VARCHAR(64) NOT NULL,  -- SHA-256 of canonical schema text
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE (schema_hash)               -- dedup identical schemas across subjects
);

-- Subject = a named, versioned sequence of schema IDs
CREATE TABLE subjects (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL UNIQUE, -- e.g., "orders-value", "orders-key"
  compatibility_mode VARCHAR(20) NOT NULL DEFAULT 'BACKWARD'
    CHECK (compatibility_mode IN ('BACKWARD','FORWARD','FULL','NONE',
                                  'BACKWARD_TRANSITIVE','FORWARD_TRANSITIVE','FULL_TRANSITIVE')),
  deleted_at TIMESTAMPTZ             -- soft delete
);

-- Mapping: subject + version -> schema ID
CREATE TABLE subject_versions (
  subject_id INTEGER NOT NULL REFERENCES subjects(id),
  version_number INTEGER NOT NULL,
  schema_id INTEGER NOT NULL REFERENCES schemas(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ,            -- soft delete a version
  PRIMARY KEY (subject_id, version_number)
);

CREATE INDEX idx_subject_versions_schema ON subject_versions(schema_id);
CREATE INDEX idx_subject_versions_latest ON subject_versions(subject_id, version_number DESC)
  WHERE deleted_at IS NULL;

The schema_hash column is the deduplication key. If two different subjects happen to evolve to the exact same schema definition, they share one row in schemas and thus one ID. This is intentional - it means two topics that happen to use the same event type get the same schema ID, and consumers looking up the ID get the correct schema regardless of which topic it came from.

The compatibility_mode on a subject governs all future version registrations. The *_TRANSITIVE variants are stricter: instead of comparing the new schema against only the latest version, they compare it against all previous versions. BACKWARD_TRANSITIVE means the new schema must be readable by consumers running any previous version, not just the most recent one.

Real World

Confluent Schema Registry (open-sourced 2015) pioneered this data model and is the de-facto standard. LinkedIn’s Gobblin project, Uber’s Schemaless system, and Airbnb’s data infrastructure all use variants of this subject/version/ID triple. AWS Glue Schema Registry (launched 2020) is a managed alternative with the same compatibility semantics but backed by AWS DynamoDB instead of Kafka’s _schemas topic. The 5-byte wire prefix format originated in Confluent’s implementation and has since been copied verbatim by most competing registries.

The Compatibility Engine

The compatibility engine is the single most important component in the registry. It answers one question for every new schema registration: “can I safely deploy this new schema version without breaking any currently running consumers?”

Backward compatibility means a new reader (consumer) can read data written by an old writer (producer). The new schema can read old messages. This is the most common requirement: producer teams add features by adding new optional fields; consumers running the old schema simply ignore fields they do not know about.

Forward compatibility means an old reader can read data written by a new writer. Old consumers can read new messages. This requires that new fields added by the producer have default values - otherwise the old consumer’s schema would expect those fields to exist and fail when they are missing.

Full compatibility means both backward and forward - any version can read any other version. This is the strictest mode and the safest for truly independent deployment of producers and consumers.

Schema compatibility matrix showing which field changes are allowed under backward, forward, full, and none modes

The compatibility algorithm for Avro works as follows:

# Avro backward compatibility check
# "Can the new_schema read messages written by old_schema?"

import json
from typing import Any

def avro_field_map(schema: dict) -> dict[str, dict]:
    """Return {field_name: field_def} for an Avro record schema."""
    return {f["name"]: f for f in schema.get("fields", [])}

def has_default(field: dict) -> bool:
    return "default" in field

def is_nullable_union(field_type: Any) -> bool:
    """True if type is ['null', 'something'] - the Avro nullable pattern."""
    return (
        isinstance(field_type, list)
        and "null" in field_type
    )

def check_backward_compatible(new_schema: dict, old_schema: dict) -> tuple[bool, list[str]]:
    """
    Check if new_schema can read messages written with old_schema.
    Returns (is_compatible, list_of_violations).
    """
    violations: list[str] = []
    old_fields = avro_field_map(old_schema)
    new_fields = avro_field_map(new_schema)

    # Rule 1: Fields removed from new schema must have had defaults in old schema
    # If old schema had a required field and new schema drops it,
    # old messages contain that field but the new schema cannot read it.
    # Actually for BACKWARD: new reader, old writer.
    # New schema must handle fields that exist in old messages.
    # Fields in old_schema not in new_schema: new reader will encounter these fields
    # in old messages. Avro silently ignores unknown fields during deserialization,
    # so this is safe. No violation.

    # Rule 2: Fields added to new schema that are NOT in old schema
    # must have defaults, because old messages won't contain them.
    for name, field in new_fields.items():
        if name not in old_fields:
            if not has_default(field) and not is_nullable_union(field.get("type")):
                violations.append(
                    f"Field '{name}' added without default: old messages lack this field, "
                    f"deserialization will fail"
                )

    # Rule 3: Field type changes are a breaking change in Avro
    # (no implicit promotion between int and long, string and bytes, etc.)
    for name, new_field in new_fields.items():
        if name in old_fields:
            old_type = old_fields[name].get("type")
            new_type = new_field.get("type")
            if old_type != new_type:
                violations.append(
                    f"Field '{name}' type changed from {old_type!r} to {new_type!r}: "
                    f"type changes break wire compatibility"
                )

    return (len(violations) == 0, violations)


def check_forward_compatible(new_schema: dict, old_schema: dict) -> tuple[bool, list[str]]:
    """
    Check if old_schema can read messages written with new_schema.
    Returns (is_compatible, list_of_violations).
    """
    # Forward: old reader, new writer.
    # Swap perspective: new schema writes, old schema reads.
    # Same as backward but with schemas swapped.
    return check_backward_compatible(old_schema, new_schema)


def check_full_compatible(new_schema: dict, old_schema: dict) -> tuple[bool, list[str]]:
    backward_ok, backward_v = check_backward_compatible(new_schema, old_schema)
    forward_ok, forward_v = check_forward_compatible(new_schema, old_schema)
    all_violations = (
        [f"[BACKWARD] {v}" for v in backward_v] +
        [f"[FORWARD] {v}" for v in forward_v]
    )
    return (backward_ok and forward_ok, all_violations)

The transitive variants extend this by iterating over all historical versions:

def check_backward_transitive(
    new_schema: dict,
    all_historical_schemas: list[dict]
) -> tuple[bool, list[str]]:
    """
    New schema must be backward compatible with EVERY historical version,
    not just the most recent one.
    """
    all_violations: list[str] = []
    for i, historical in enumerate(all_historical_schemas):
        ok, violations = check_backward_compatible(new_schema, historical)
        if not ok:
            for v in violations:
                all_violations.append(f"[vs v{i+1}] {v}")
    return (len(all_violations) == 0, all_violations)
Key Insight

Transitive compatibility is the correct default for most production systems, but it is almost never used because it makes schema evolution painful. The practical alternative is to roll out consumer migrations before producer upgrades: deploy all consumers that can read the new schema, verify they are running, then deploy the producer. This achieves the effect of transitive compatibility without requiring it to be encoded in the registry rule itself.

Schema Evolution Rules in Practice

Schema evolution is less about the registry rules and more about the discipline of using Avro’s union type correctly. Every optional field in an Avro schema should use the union pattern:

{
  "type": "record",
  "name": "OrderPlaced",
  "namespace": "com.example.orders",
  "fields": [
    {
      "name": "orderId",
      "type": "string",
      "doc": "Globally unique order identifier (UUID)"
    },
    {
      "name": "customerId",
      "type": "string"
    },
    {
      "name": "totalAmountCents",
      "type": "long"
    },
    {
      "name": "currencyCode",
      "type": "string",
      "default": "USD",
      "doc": "ISO 4217 currency code. Added in v2, defaults to USD for v1 message backcompat."
    },
    {
      "name": "promoCode",
      "type": ["null", "string"],
      "default": null,
      "doc": "Optional promotional code. null if no promo applied."
    },
    {
      "name": "shippingAddress",
      "type": {
        "type": "record",
        "name": "Address",
        "fields": [
          {"name": "street", "type": "string"},
          {"name": "city", "type": "string"},
          {"name": "countryCode", "type": "string"},
          {
            "name": "postalCode",
            "type": ["null", "string"],
            "default": null
          }
        ]
      }
    }
  ]
}

The evolution rules that keep producers and consumers independently deployable:

  1. Adding a field: Always add with a default value (scalar default or ["null", T] with default: null). New consumer sees the default for old messages; old consumer ignores the field in new messages.

  2. Removing a field: Safe under FORWARD only - the new producer omits the field; old consumers using the old schema will receive a default value from Avro’s schema projection. Under BACKWARD, the consumer using the new schema reads old messages that contain the field, but since the field is removed from the schema, Avro projects it away silently. Requires default to have been set on the removed field originally.

  3. Renaming a field: Never safe under strict compatibility. The rename is treated as a delete of the old name and an add of the new name. The correct approach is using Avro aliases - the new field retains its new name but lists the old name as an alias. Avro’s schema projection will match the old field in the message to the new field by alias.

{
  "name": "customer_id",
  "type": "string",
  "aliases": ["customerId"],
  "doc": "Renamed from customerId in v3. Aliases maintain backward read compatibility."
}
  1. Changing a type: Never safe under any strict compatibility mode in standard Avro. The only option is to add a new field with the new type alongside the old field, then deprecate the old one.
Watch Out

The most common mistake is treating BACKWARD compatibility as “safe for all deployments.” It is only safe if you always deploy consumers before producers. BACKWARD means new consumers can read old messages - but if you deploy the new producer first, old consumers running in production will receive new messages they cannot parse. For truly independent deployment order, you need FULL compatibility, not just BACKWARD.

The Producer-Consumer Contract

The producer-consumer schema contract is enforced at two integration points: the CI/CD pipeline (pre-deployment) and the producer’s serializer (runtime, as a last line of defense).

CI/CD integration is the most important enforcement point. Before merging a PR that changes a schema, the build pipeline calls the registry’s compatibility check endpoint:

# CI step: verify schema compatibility before merging
# This runs against the staging registry which has production schemas mirrored

SCHEMA_FILE="src/main/avro/OrderPlaced.avsc"
SUBJECT="orders-value"
REGISTRY_URL="https://schema-registry-staging.internal"

# Read the proposed new schema
NEW_SCHEMA=$(cat "$SCHEMA_FILE" | jq -c .)

# Ask the registry: would this registration succeed?
RESPONSE=$(curl -s -w "\n%{http_code}" \
  -X POST "$REGISTRY_URL/compatibility/subjects/$SUBJECT/versions/latest" \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d "{\"schema\": $(echo $NEW_SCHEMA | jq -Rs .)}")

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)

if [ "$HTTP_CODE" != "200" ]; then
  echo "ERROR: Schema compatibility check failed (HTTP $HTTP_CODE)"
  echo "$BODY" | jq .
  exit 1
fi

IS_COMPATIBLE=$(echo "$BODY" | jq -r '.is_compatible')
if [ "$IS_COMPATIBLE" != "true" ]; then
  echo "ERROR: Schema is not compatible with current version"
  echo "$BODY" | jq .
  exit 1
fi

echo "Schema is compatible. Proceeding."

Runtime serializer integration catches schemas that were registered without going through CI (e.g., manual registrations, test environments). The KafkaAvroSerializer from Confluent’s client library integrates with the registry automatically:

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 registry client - will cache schema IDs locally after first lookup
schema_registry_conf = {
    "url": "https://schema-registry.internal",
    "basic.auth.user.info": "api-key:api-secret",
}
schema_registry_client = SchemaRegistryClient(schema_registry_conf)

# Load schema from file (in practice, generated from your Avro definition)
order_placed_schema_str = open("OrderPlaced.avsc").read()

avro_serializer = AvroSerializer(
    schema_registry_client,
    order_placed_schema_str,
    # Optional: transform Python dict to the expected structure
    lambda obj, ctx: obj,
)

producer_conf = {
    "bootstrap.servers": "kafka-broker-1:9092,kafka-broker-2:9092",
}
producer = Producer(producer_conf)

def produce_order_placed(order_id: str, customer_id: str, total_cents: int):
    event = {
        "orderId": order_id,
        "customerId": customer_id,
        "totalAmountCents": total_cents,
        "currencyCode": "USD",
        "promoCode": None,
        "shippingAddress": {
            "street": "123 Main St",
            "city": "Seattle",
            "countryCode": "US",
            "postalCode": "98101",
        }
    }

    producer.produce(
        topic="orders",
        key=order_id,
        value=avro_serializer(event, SerializationContext("orders", MessageField.VALUE)),
        on_delivery=lambda err, msg: (
            print(f"Delivered to {msg.topic()} [{msg.partition()}]")
            if not err else print(f"Delivery failed: {err}")
        ),
    )
    producer.flush()

The consumer side mirrors this with AvroDeserializer:

from confluent_kafka import Consumer
from confluent_kafka.schema_registry.avro import AvroDeserializer
from confluent_kafka.serialization import SerializationContext, MessageField

avro_deserializer = AvroDeserializer(
    schema_registry_client,
    # Reader schema: what the consumer understands
    # Can be older than the writer schema - Avro will project
    order_placed_schema_str,
)

consumer_conf = {
    "bootstrap.servers": "kafka-broker-1:9092",
    "group.id": "analytics-service",
    "auto.offset.reset": "earliest",
    "enable.auto.commit": False,
}
consumer = Consumer(consumer_conf)
consumer.subscribe(["orders"])

while True:
    msg = consumer.poll(timeout=1.0)
    if msg is None:
        continue
    if msg.error():
        continue

    # Deserializer reads 5-byte prefix, looks up schema by ID, projects
    order = avro_deserializer(
        msg.value(),
        SerializationContext(msg.topic(), MessageField.VALUE)
    )

    # order is a Python dict with the fields from the reader schema
    process_order(order)
    consumer.commit(asynchronous=False)
Real World

Confluent’s KafkaAvroSerializer has a two-level caching strategy: first a ConcurrentHashMap in the JVM process (permanent for the process lifetime, zero network cost after first register), then a client-side LRU cache for lookup-only calls. In a high-throughput producer at 100,000 messages/second, the schema ID lookup would be 100,000 network calls per second if not cached - which is why the process-lifetime cache is non-negotiable for production use. LinkedIn’s Kafka clients use the same pattern, as does the AWS Glue Schema Registry integration library.

Avro vs Protobuf

The choice between Avro and Protobuf as the schema format has significant implications for how the registry manages compatibility.

Avro uses a JSON schema definition language. Field identity is by name, not by number. Schema projection (reading with a different schema than was used to write) happens at read time, requires both the writer and reader schemas to be available, and is performed by the Avro library using name matching and defaults. This is why the registry’s lookup-by-ID is on the critical path for every message deserialization - the consumer must retrieve the writer schema to know how to decode the bytes.

Protobuf uses a .proto definition language. Field identity is by field number, not name. Field numbers are stable across renames, which means renaming a field in Protobuf is wire-compatible (unlike Avro). Protobuf decoders are forward-compatible by default - unknown field numbers are preserved in wire format and round-tripped, they are not silently dropped. This makes Protobuf schemas naturally more resilient to evolution.

// Protobuf schema for OrderPlaced - field numbers are the stable identity
syntax = "proto3";
package com.example.orders;

message OrderPlaced {
  string order_id = 1;          // field number 1: never reuse this
  string customer_id = 2;       // field number 2: can rename the field, not the number
  int64 total_amount_cents = 3;
  string currency_code = 4;     // safe to add; proto3 has implicit defaults
  optional string promo_code = 5;
  Address shipping_address = 6;

  // Reserved field numbers/names for removed fields - prevents accidental reuse
  reserved 7, 8;
  reserved "legacy_user_id", "referral_code";
}

message Address {
  string street = 1;
  string city = 2;
  string country_code = 3;
  optional string postal_code = 4;
}

The key Protobuf evolution rule: never reuse field numbers. When you remove a field, use reserved to tombstone that number forever. This prevents a future field from accidentally reusing the number and causing wire-level type mismatches (e.g., a removed int64 field number 7 accidentally reused for a string field would cause silent data corruption in old consumers that still have the old definition).

PropertyAvroProtobuf
Field identityName-basedNumber-based
Rename safetyBreaking (without aliases)Safe (keep field number)
Default valuesExplicit, required for evolutionImplicit (proto3 zero values)
Schema on readRequired (writer schema needed)Not required
Registry dependencyHard (every message decode)Soft (optional for lookup)
Schema sizeCompact JSONCompact binary
Code generationOptionalIdiomatic (protoc)
Key Insight

Avro’s dependency on the writer schema at decode time is often described as a weakness, but it is actually what makes Avro schema projection powerful. Avro can evolve a field’s semantics (rename, reorder, add aliases) without breaking the wire format, because the projection logic has both schemas available to reconcile differences. Protobuf’s self-contained decoding is faster but more rigid - once a field number is assigned, its type is permanent in the wire format.

Data Model

The registry’s complete operational data model includes not just the core schema tables but also the audit trail for compliance and the cache invalidation mechanism for distributed deployments.

-- Full production schema registry data model

-- Core schemas table (content-addressed by hash)
CREATE TABLE schemas (
  id BIGSERIAL PRIMARY KEY,
  schema_type VARCHAR(20) NOT NULL CHECK (schema_type IN ('AVRO', 'PROTOBUF', 'JSON')),
  schema_text TEXT NOT NULL,
  schema_hash CHAR(64) NOT NULL,    -- SHA-256 hex of normalized schema text
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (schema_hash)
);

CREATE INDEX idx_schemas_hash ON schemas(schema_hash);

-- Subjects table
CREATE TABLE subjects (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(512) NOT NULL UNIQUE,
  compatibility_mode VARCHAR(25) NOT NULL DEFAULT 'BACKWARD',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);

-- Version history per subject
CREATE TABLE subject_versions (
  subject_id BIGINT NOT NULL REFERENCES subjects(id),
  version_number INTEGER NOT NULL,
  schema_id BIGINT NOT NULL REFERENCES schemas(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_by VARCHAR(255),           -- service account or user who registered
  deleted_at TIMESTAMPTZ,
  PRIMARY KEY (subject_id, version_number)
);

CREATE INDEX idx_sv_subject_active ON subject_versions(subject_id, version_number DESC)
  WHERE deleted_at IS NULL;

-- Audit log for all registry operations
CREATE TABLE registry_audit_log (
  id BIGSERIAL PRIMARY KEY,
  event_type VARCHAR(50) NOT NULL,   -- SCHEMA_REGISTERED, VERSION_DELETED, MODE_CHANGED
  subject_id BIGINT REFERENCES subjects(id),
  schema_id BIGINT REFERENCES schemas(id),
  actor VARCHAR(255),
  details JSONB,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_subject ON registry_audit_log(subject_id, occurred_at DESC);
CREATE INDEX idx_audit_recent ON registry_audit_log(occurred_at DESC);

-- Cache invalidation events for distributed registry replicas
CREATE TABLE cache_invalidation_events (
  id BIGSERIAL PRIMARY KEY,
  invalidated_schema_id BIGINT,
  invalidated_subject_id BIGINT,
  event_type VARCHAR(50) NOT NULL,   -- SCHEMA_ADDED, VERSION_DELETED, MODE_CHANGED
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Subject naming convention: Confluent’s standard convention maps a Kafka topic orders to two subjects: orders-key (for the key schema) and orders-value (for the value schema). This allows independent schema evolution for keys and values. The {topic}-value subject is the one that undergoes most evolution; the {topic}-key subject usually contains just an ID and is rarely changed.

Schema hash deduplication: The schema_hash column on the schemas table ensures that two registrations of identical schema text produce the same integer ID. This is critical for multi-environment setups - if staging and production both register the same OrderPlaced v1 schema independently, they get the same ID, so a message produced in staging with schema_id=42 can be decoded in production with the same schema_id=42.

Watch Out

Schema deduplication via hash requires canonical normalization before hashing. Two JSON strings that represent the same Avro schema but differ in whitespace, field order, or optional default values should produce the same hash. Failure to normalize before hashing causes phantom duplicate schemas where the same logical schema occupies multiple IDs, and producers in different environments end up with different IDs for the same data - breaking the cross-environment compatibility guarantee.

Key Algorithms and Protocols

The 5-Byte Wire Format

The wire format is deceptively simple but carries significant engineering decisions. Each Avro-encoded message written to Kafka is prefixed with exactly 5 bytes:

Byte 0:     Magic byte = 0x00  (signals that the message uses the registry protocol)
Bytes 1-4:  Schema ID          (big-endian unsigned 32-bit integer)
Bytes 5...: Avro binary payload
import struct

MAGIC_BYTE = 0x00

def encode_with_schema_id(schema_id: int, avro_bytes: bytes) -> bytes:
    """Prepend the 5-byte registry header to an Avro-encoded message."""
    header = struct.pack(">bI", MAGIC_BYTE, schema_id)  # big-endian: 1 byte + 4 bytes
    return header + avro_bytes

def decode_schema_id(message_bytes: bytes) -> tuple[int, bytes]:
    """Extract the schema ID and payload from a registry-encoded message."""
    if len(message_bytes) < 5:
        raise ValueError(f"Message too short to contain registry header: {len(message_bytes)} bytes")

    magic, schema_id = struct.unpack(">bI", message_bytes[:5])

    if magic != MAGIC_BYTE:
        raise ValueError(
            f"Invalid magic byte: expected {MAGIC_BYTE:#x}, got {magic:#x}. "
            "Message may not be Avro-encoded or may use a different registry protocol."
        )

    return schema_id, message_bytes[5:]

The >bI struct format string means big-endian (>), signed byte (b), unsigned 32-bit int (I). The maximum schema ID representable in 4 bytes is 4,294,967,295 - more than sufficient for any realistic deployment.

Schema ID Resolution with Tiered Caching

The ID resolution path must be fast enough to not be visible in producer latency. At 100,000 messages/second from a single service, a 1ms cache miss would add 100 seconds of serializer waiting per second of work - obviously impossible. The solution is aggressive, multi-level caching:

import asyncio
import aiohttp
from functools import lru_cache
from typing import Optional
import time

class SchemaCache:
    """
    Two-level cache for schema registry lookups.
    L1: in-process dict (permanent, zero latency)
    L2: Redis (shared across processes, 5min TTL)
    L3: Registry API (single-flight to prevent thundering herd)
    """
    def __init__(self, registry_url: str, redis_client, l1_max_size: int = 10000):
        self._registry_url = registry_url
        self._redis = redis_client
        self._l1: dict[int, str] = {}           # schema_id -> schema_text
        self._l1_max_size = l1_max_size
        self._inflight: dict[int, asyncio.Future] = {}  # schema_id -> in-progress fetch

    async def get_schema_by_id(self, schema_id: int) -> str:
        # L1: process-local dict (zero network, zero alloc)
        if schema_id in self._l1:
            return self._l1[schema_id]

        # L2: Redis cache (sub-millisecond network, shared across processes)
        redis_key = f"schema:id:{schema_id}"
        cached = await self._redis.get(redis_key)
        if cached:
            schema_text = cached.decode("utf-8")
            self._l1[schema_id] = schema_text  # promote to L1
            return schema_text

        # L3: Registry API - with single-flight deduplication
        # If 100 consumers all miss cache for schema_id=42 simultaneously,
        # we send exactly ONE request to the registry and share the result.
        if schema_id in self._inflight:
            return await asyncio.shield(self._inflight[schema_id])

        future: asyncio.Future = asyncio.get_event_loop().create_future()
        self._inflight[schema_id] = future

        try:
            schema_text = await self._fetch_from_registry(schema_id)
            # Populate both caches
            await self._redis.setex(redis_key, 300, schema_text)  # 5min TTL
            self._l1[schema_id] = schema_text
            future.set_result(schema_text)
            return schema_text
        except Exception as e:
            future.set_exception(e)
            raise
        finally:
            self._inflight.pop(schema_id, None)

    async def _fetch_from_registry(self, schema_id: int) -> str:
        async with aiohttp.ClientSession() as session:
            url = f"{self._registry_url}/schemas/ids/{schema_id}"
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=2.0)) as resp:
                if resp.status == 404:
                    raise KeyError(f"Schema ID {schema_id} not found in registry")
                resp.raise_for_status()
                data = await resp.json()
                return data["schema"]

The single-flight pattern at L3 is the key to preventing thundering herd: if 500 consumer threads simultaneously encounter a new schema ID (because a producer just deployed with a new schema version), exactly one of them makes the actual HTTP request to the registry. The other 499 await the same future. Without this, a new schema version deployment would generate a burst of 500+ registry requests from every consumer process simultaneously.

Key Insight

Schema IDs are immutable - a registered schema ID never changes its meaning. This makes L1 caching safe to hold indefinitely without cache invalidation. The only schema cache that needs a TTL is one for the “subject -> latest version” mapping, which changes when new versions are registered. The ID-to-schema cache can be permanent for the process lifetime.

Breaking Change Detection Algorithm

The breaking change detector runs as part of the registration endpoint and also as a standalone CLI tool for CI integration:

def detect_breaking_changes(
    subject: str,
    new_schema_text: str,
    current_schema_text: str,
    mode: str
) -> list[str]:
    """
    Return a list of human-readable breaking change descriptions.
    Empty list means the change is safe under the given mode.
    """
    import json
    new_schema = json.loads(new_schema_text)
    old_schema = json.loads(current_schema_text)

    if mode == "NONE":
        return []
    elif mode == "BACKWARD" or mode == "BACKWARD_TRANSITIVE":
        _, violations = check_backward_compatible(new_schema, old_schema)
        return violations
    elif mode == "FORWARD" or mode == "FORWARD_TRANSITIVE":
        _, violations = check_forward_compatible(new_schema, old_schema)
        return violations
    elif mode == "FULL" or mode == "FULL_TRANSITIVE":
        _, violations = check_full_compatible(new_schema, old_schema)
        return violations
    else:
        raise ValueError(f"Unknown compatibility mode: {mode}")


# REST endpoint handler
def handle_register_schema(subject: str, schema_text: str) -> dict:
    """
    POST /subjects/{subject}/versions
    Register a new schema version. Enforces compatibility.
    Returns the schema ID.
    """
    # Normalize schema text before hashing
    import json
    normalized = json.dumps(json.loads(schema_text), sort_keys=True, separators=(",", ":"))
    schema_hash = hashlib.sha256(normalized.encode()).hexdigest()

    # Check if this exact schema already exists (dedup)
    existing = db.query(
        "SELECT id FROM schemas WHERE schema_hash = %s", (schema_hash,)
    )
    if existing:
        schema_id = existing[0]["id"]
        # Still need to register the version under this subject if not already there
        return {"id": schema_id}

    # Load the current version of this subject
    current_version = db.query(
        """SELECT s.schema_text FROM subjects sub
           JOIN subject_versions sv ON sv.subject_id = sub.id
           JOIN schemas s ON s.id = sv.schema_id
           WHERE sub.name = %s AND sv.deleted_at IS NULL
           ORDER BY sv.version_number DESC LIMIT 1""",
        (subject,)
    )

    if current_version:
        mode = db.query(
            "SELECT compatibility_mode FROM subjects WHERE name = %s", (subject,)
        )[0]["compatibility_mode"]

        violations = detect_breaking_changes(
            subject, normalized, current_version[0]["schema_text"], mode
        )
        if violations:
            raise CompatibilityError(
                f"Schema is not {mode} compatible with version {current_version[0]['version_number']}: "
                + "; ".join(violations)
            )

    # Assign new schema ID and register version
    schema_id = db.execute(
        "INSERT INTO schemas (schema_type, schema_text, schema_hash) VALUES ('AVRO', %s, %s) RETURNING id",
        (normalized, schema_hash)
    )[0]["id"]

    # Increment version number for this subject
    db.execute(
        """INSERT INTO subject_versions (subject_id, version_number, schema_id, created_by)
           SELECT id,
             COALESCE((SELECT MAX(version_number) FROM subject_versions WHERE subject_id = sub.id), 0) + 1,
             %s, %s
           FROM subjects sub WHERE sub.name = %s""",
        (schema_id, current_user(), subject)
    )

    return {"id": schema_id}

Scaling and Performance

Multi-region HA schema registry setup with primary in US-EAST and standby in EU-WEST using Kafka MirrorMaker replication

The throughput challenge for the schema registry is asymmetric: writes (registrations) are rare (a few per week per team), but reads (schema ID lookups) happen at Kafka message volume - potentially millions per second. The scaling strategy reflects this.

Read scaling via process-local caching. As described in the caching section, the producer and consumer processes cache schema IDs permanently in process memory. This means the registry API receives reads only on first encounter with a schema ID. In steady state, a fleet of 1,000 consumer instances produces zero registry read requests - they all hit process-local cache.

Write scaling via leader/follower. Schema registrations must be linearizable - two simultaneous registrations of v2 for the same subject should not both succeed; only one should win and the other should get back the ID of the registered version. We use a single leader for writes, enforced via ZooKeeper or Raft leader election. Reads are served by all replicas.

The _schemas Kafka topic as source of truth. Rather than a relational database, Confluent’s Schema Registry uses a single-partition Kafka topic named _schemas as its primary store. On startup, the registry rebuilds its in-memory state by replaying this topic. New schemas are appended to the topic; the in-memory state is updated on append. This design makes the registry stateless from a persistence perspective and allows replicas to catch up simply by consuming the topic.

Back-of-envelope capacity estimation:

Given:
  - 50 Kafka topics, each with 2 subjects (key + value)
  - 2 new schema versions per week per subject = 200 versions/week total
  - Schema text average size: 1,500 bytes (small Avro JSON)
  - Schema ID lookup: 1M lookups/day (mostly from process restarts and cold starts)
  - Process-local cache hit rate: 99.9% (only first lookup per process per schema)

Storage:
  5,000 total schema versions * 1,500 bytes = 7.5 MB (trivially small)
  With 10x growth over 5 years: 75 MB

Kafka _schemas topic:
  200 versions/week * 1,500 bytes = 300 KB/week
  After 5 years: ~78 MB (including topic overhead)

Registry API throughput:
  1M lookups/day = ~11.5 req/sec average
  At 99.9% cache hit rate, only 11.5 * 0.001 = 0.012 API calls/sec
  Peak (mass restart): 1,000 processes * 200 schemas = 200,000 cold lookups
    -> 200,000 / 60 seconds = ~3,300 req/sec burst

Memory per registry node:
  5,000 schemas * 1,500 bytes = 7.5 MB raw schema storage
  Index structures: ~50 MB total
  Redis cache: 5,000 schemas * 1,500 bytes * 2 (key overhead) = ~15 MB

Conclusion:
  Schema volume is tiny. The challenge is burst availability,
  not capacity. Size the registry for 10x peak API burst,
  not average schema volume.

Hot path optimization: The single-partition _schemas topic creates a write bottleneck for very high schema registration rates. In practice, 200 registrations per week is 0.0003 registrations per second - no bottleneck whatsoever. The partition limit of a Kafka topic is irrelevant at this write rate.

Cross-region replication uses Kafka MirrorMaker 2 to replicate the _schemas topic from the primary region to standby regions. The standby registry instances consume from the mirrored topic and maintain an identical in-memory state. Reads in the standby region are served locally with sub-millisecond latency. Writes in the standby region are proxied to the primary.

Real World

Confluent Cloud’s managed Schema Registry serves hundreds of millions of schema lookups daily at sub-1ms p99. Their architecture is precisely this: single-partition _schemas topic as source of truth, in-memory state rebuilt on startup, Redis for cross-process cache sharing, and ZooKeeper for leader election on the write path. Uber’s internal schema registry (“Schema Store”) took a different approach - they use a MySQL-backed service with a read-through cache, accepting slightly higher storage complexity in exchange for rich SQL query capabilities for schema governance and auditing.

Failure Modes and Recovery

FailureDetectionImpactRecovery
Registry API down (all instances)Health check failure; producer gets connection refusedNew schema registrations fail; cold-start consumer processes that need schema lookup stallProducers with cached IDs continue unaffected. Consumers with empty cache stall. Restart registry. Consumers retry with exponential backoff.
Primary registry loses leadershipZooKeeper session timeoutWrite requests rejected (no leader to serialize writes)ZooKeeper elects new leader from replicas within 10s. Writes resume. Reads unaffected throughout.
_schemas Kafka topic unavailableRegistry startup fails to replay topicRegistry cannot start fresh; existing instances continue from in-memory stateRestart Kafka brokers. Registry reconnects and resumes consumption. Never delete the _schemas topic.
Schema ID collision in multi-regionDivergent schema hash normalizationSame schema assigned different IDs in different regionsCross-environment ID mismatch causes decode failures. Fix: standardize normalization, re-sync _schemas topic via MirrorMaker.
Producer deploys incompatible schemaSerializer calls registry; registry returns 409Producer fails to serialize messages; no messages sentProducer build fails in CI before deployment reaches this state. If it reaches runtime, the exception from AvroSerializer surfaces immediately.
Consumer encounters unknown schema IDCache miss; registry returns 404Deserialization fails; message unprocessableDead letter queue the message. Investigate: was the schema soft-deleted? Re-register the schema.
Watch Out

The most dangerous operational mistake is deleting a schema version that is still referenced by messages sitting in Kafka. Even if no producer is actively using that schema version, there may be messages written weeks ago still available in Kafka (depending on retention). A consumer that replays from the beginning of the topic will encounter those messages and fail to deserialize them because the schema ID 404s. Schema versions should be soft-deleted only, and only after confirming all messages using that schema ID have been consumed or the topic’s retention has expired.

Comparison of Approaches

ApproachCompatibility EnforcementSchema ResolutionEvolution SupportOperational Complexity
Confluent Schema Registry (Avro)Compile-time (CI) + runtime5-byte ID in wire format, cacheRich (backward/forward/full/transitive)Medium (self-hosted or cloud)
AWS Glue Schema RegistryCI + runtimeSame wire format, DynamoDB-backedSame modes as ConfluentLow (fully managed)
Protobuf without registryNone (convention only)Full schema embedded or known staticallyGood via field numbersNone
JSON Schema with registryRuntime only (no binary checks)Schema embedded in message or by URIWeak (no Avro-style projection)Medium
No schema managementNoneN/ANone (breaking changes are invisible)Zero (until production incident)

The practical recommendation: for any event-driven system with more than 2 producer/consumer pairs, use Confluent Schema Registry with Avro and BACKWARD compatibility as the default mode. Set individual high-traffic subjects to FULL for topics where independent deployment is critical. Set read-only reference data topics to NONE (they are controlled and rarely change). Wire the compatibility check into CI/CD so it runs on every PR that touches a schema file. This combination catches breaking changes before they ship without requiring the strictest compatibility mode universally.

Protobuf is the better choice if your team already uses gRPC extensively (shared .proto definitions, existing code generation pipeline) or if you need schema-free consumers (Protobuf’s binary format can be decoded without a registry using only the field numbers). Avro wins on schema governance, richness of projection semantics, and tooling ecosystem for Kafka specifically.

Key Takeaways

  • Schema evolution rules: adding optional fields with defaults is always safe; removing fields requires care under backward compatibility; renaming fields is a breaking change without Avro aliases.
  • The 5-byte wire format (magic byte + 4-byte schema ID) is what makes consumer-independent evolution possible - consumers use the ID to fetch the exact schema used by the writer.
  • Backward vs forward compatibility: backward means new consumers read old data; forward means old consumers read new data; full means both simultaneously. Most teams want full but default to backward.
  • Schema ID immutability is what makes process-lifetime caching safe and enables the high-throughput lookup path.
  • Avro vs Protobuf: Avro requires the writer schema at decode time (harder to use without a registry but enables rich projection); Protobuf uses field numbers (self-describing, rename-safe, but no semantic projection).
  • Producer-consumer contract enforcement in CI/CD is more valuable than runtime enforcement - catching a breaking change in a PR review prevents incidents; catching it in a running producer means consumers are already failing.
  • The single-flight deduplication pattern in the cache prevents thundering herd on new schema deployments, where hundreds of consumer processes simultaneously encounter an unknown schema ID.
  • Soft deletes only: never hard-delete a schema version while Kafka topics retain messages encoded with that schema ID.

The counter-intuitive lesson from building a schema registry is that the hard problem is not the compatibility algorithm - the Avro and Protobuf specifications define the rules precisely. The hard problem is getting teams to treat schemas as versioned contracts in their development workflow, the same way they treat API contracts. A schema registry only works when developers check in schema changes to version control, run compatibility checks in CI, and review schema changes with the same rigor as API surface changes. The technology is easy; the process change is the actual engineering challenge.

Frequently Asked Questions

Q: Why not just use JSON for Kafka messages and skip the registry entirely?

A: JSON is self-describing, which means every message carries full field names and types as strings - typically 3-5x the size of equivalent Avro binary. At 100,000 messages/second with 500-byte payloads, that is 50 MB/sec vs 150 MB/sec in broker bandwidth, storage, and consumer read cost. More importantly, JSON gives you no enforcement mechanism - a developer who renames a field will not get any warning until consumers start failing. The registry is the enforcement point that makes schema evolution intentional rather than accidental.

Q: What is the right compatibility mode to configure by default?

A: BACKWARD is the Confluent default and the most commonly deployed. It allows producer teams to add new fields freely as long as they have defaults. The risk is that if you deploy a producer before all consumers are updated, old consumers will encounter new fields they do not understand - which Avro handles gracefully by ignoring them. FULL is safer for true independent deployment but requires that all new fields have defaults and no fields are removed. Start with BACKWARD, move specific high-traffic subjects to FULL as your team’s schema discipline matures.

Q: How do you handle schema changes for topics with years of historical data in Kafka?

A: With careful planning. If you need to make a breaking change (rename a field, change a type), the correct approach is: (1) create a new topic with the new schema and start dual-writing from the producer; (2) migrate all consumers to read from the new topic; (3) deprecate the old topic and drain remaining consumers; (4) stop dual-writing once the old topic’s retention has expired. This is a 2-4 week process but is the only safe way to make a truly breaking change without consumer downtime. Alternatively, use the Avro alias mechanism to make the “rename” backward-compatible without touching the underlying topic.

Q: Can the schema registry become a bottleneck or single point of failure for Kafka throughput?

A: Not with proper client-side caching. After a process starts and warms its schema ID cache, it makes zero registry calls for every subsequent message it sends or receives. The registry is only on the critical path for cold starts and new schema deployments. The failure mode to guard against is a total registry outage during a mass fleet restart - 1,000 processes all cold-starting simultaneously - which is why the multi-level caching with Redis as L2 is important. With Redis as L2, even a registry outage does not stop producers that have already registered their schemas; consumers can look up known schema IDs from Redis.

Q: How do you test schema compatibility in a local development environment?

A: Run a local Docker schema registry instance (Confluent provides an official image) and configure your serializers to point to localhost:8081. Check schema compatibility locally with curl before committing. Better yet, write a unit test that loads the current production schema from the CI environment and runs the compatibility check as part of your test suite - this catches regressions before the CI check even runs. The confluent-schema-registry-maven-plugin or equivalent can automate this check in build pipelines.

Q: Why does the registry use an integer ID instead of a content hash as the schema reference?

A: Integer IDs are 4 bytes; a SHA-256 hash is 32 bytes. At 100,000 messages/second, that 28-byte difference is 2.8 MB/sec of extra Kafka traffic that carries no information. Integer IDs also support versioned references without ambiguity - schema_id=42 always means the exact same schema, even if two different schemas have the same SHA-256 hash (extremely unlikely but theoretically possible with a 32-byte hash collision). The registry stores the hash internally for deduplication but exposes only integer IDs on the wire.

Interview Questions

Q: Design the compatibility enforcement mechanism for a schema registry. How do you check that a new Avro schema version is backward compatible with the previous version?

Expected depth: Walk through the field-level rules: new required fields break backward compat, removed fields are safe with defaults, type changes break compatibility. Explain the role of Avro defaults and the ["null", type] union pattern. Discuss transitive vs. non-transitive modes. Mention the CI check endpoint vs. runtime check. Show you understand that Avro schema projection uses both writer and reader schemas at decode time.

Q: A new schema version is deployed to production. 50 consumer processes simultaneously try to decode a message with the new schema ID and all miss their caches. How do you prevent a thundering herd on the registry?

Expected depth: The single-flight/promise-collapsing pattern at the process level. The Redis shared cache as L2 to prevent cross-process duplicate requests. Circuit breaker on the registry client to fail fast if the registry is down. Discuss that schema IDs are immutable so the L2 cache TTL can be set very long (hours or infinite). Mention the registry’s stateless read replicas can be scaled horizontally to absorb read bursts.

Q: How would you handle a scenario where a producer team needs to rename a field in their event schema? What is the migration path?

Expected depth: Renaming is a breaking change under strict compatibility. The safe path: add the new field name alongside the old field name (both present in v2), add aliases in Avro so new consumers can read old messages using the alias, deploy all consumers to the new dual-field schema, then in v3 remove the old field name after confirming no consumer reads it. Discuss the alternative of treating rename as new topic plus dual-write migration. The interviewer wants to see you recognize that the technical solution (aliases) exists but the organizational challenge (coordinating all consumer teams) is the real constraint.

Q: The schema registry is a potential single point of failure for your entire Kafka-based event pipeline. How do you make it highly available?

Expected depth: The asymmetry between reads and writes - reads are on the critical path (producer/consumer hot loop), writes are rare. Process-local caching means the registry is not truly on the hot path for steady-state traffic. Leader/follower architecture for writes with ZooKeeper leader election. Multi-region passive replicas via _schemas topic MirrorMaker replication. The failure mode discussion: what happens during a full outage (producers with cached IDs continue; new consumers stall). Circuit breaker to serve stale cached schemas during outage. max_schema_versions_in_cache=-1 for unlimited process-local caching.

Q: You need to support both Avro and Protobuf schemas in the same registry. How does this affect the compatibility engine and wire format?

Expected depth: Separate compatibility engines per format - Avro uses field name matching and defaults, Protobuf uses field number stability and reserved numbers. The 5-byte wire format is format-agnostic (the schema ID alone identifies both the schema and its type). Subjects should be locked to a single schema type (cannot mix Avro and Protobuf versions within one subject). Discuss the tradeoffs: Avro needs the writer schema at decode time (registry on critical path), Protobuf does not (registry is advisory). Mention that the Confluent Registry supports this via a schemaType field in the API.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article