Serialization and Schema Design
Why raw, unvalidated JSON breaks production Kafka pipelines at scale, how Avro and Protobuf compare to JSON, what a Schema Registry actually does, and the backward/forward compatibility rules that let producers and consumers evolve independently without breaking each other.
Kafka Stores Bytes — Everything Else Is a Contract You Have to Enforce Yourself
A Kafka partition does not know or care what is inside a record's value. To the broker, every message is just an opaque byte array with an offset. This is a deliberate, useful design — Kafka does not need to understand your data to store and replicate it correctly. But it also means Kafka gives you absolutely no help enforcing what shape that data is supposed to be. That enforcement, often called the "schema," is entirely the producer and consumer's responsibility, and how a team chooses to handle it is one of the highest-leverage decisions in any Kafka-based system, because getting it wrong doesn't fail loudly — it fails quietly, weeks or months later, in a way that's expensive to trace back to its origin.
Most teams start with raw JSON, because it requires no extra tooling: serialize a dictionary or object to a JSON string, send the bytes, and have the consumer parse it back withjson.loads(). This works fine for a prototype, a single team, or a low-stakes internal topic. It becomes a genuine production risk once a topic has multiple independent producers and consumers, evolves over months or years, or crosses team boundaries — because JSON, on its own, enforces nothing.
Beginner model: JSON is simple, human-readable, and works everywhere — why would I need anything more structured?
Production model: JSON's flexibility is exactly what makes it dangerous at scale. Nothing stops a producer from silently renaming a field, changing an integer to a string, or dropping a field entirely — the code will happily serialize the new shape, and the consumer will only find out when it crashes on a null-pointer error or, worse, silently mis-parses a field into the wrong type and keeps running with corrupted data. A schema — enforced by a serialization format and, in production, a Schema Registry (Part 03) — turns that silent failure into a compile-time or produce-time error, long before it reaches a consumer.
Three concrete ways raw JSON goes wrong
| Failure mode | What actually happens | Why JSON does not catch it |
|---|---|---|
| Silent breaking change | A producer team renames order_total to total_amount in a routine refactor. Every consumer expecting order_total now silently reads a missing field as null or undefined. | JSON has no schema to validate a producer's output against before it is sent — the string serializes successfully regardless of what changed. |
| Type drift | A field that was always an integer (price_cents: 1999) starts arriving as a string ("1999") after a producer-side library upgrade changes default number formatting. | JSON's type system is permissive by default; nothing forces the producer to declare or enforce a field's type across every write path. |
| No validation at write time | A producer bug sends a record missing a required field entirely. The bad record is written, replicated, and retained for the topic's full retention window before anyone notices. | There is no schema for the broker or producer client to check the record against — a malformed record looks exactly like a valid one until a consumer tries to use the missing field. |
There is also a quieter, purely operational cost: JSON is verbose. Every record repeats every field name as a full string, every time, for every message. At high volume, this bloats network traffic and disk usage compared to a binary format that only needs to send field names once, in a schema, and then just the values afterward. This alone is rarely decisive on its own, but it compounds with the correctness risk above to make JSON a genuinely worse default than most teams realize at Kafka scale.
JSON, Avro, and Protobuf Trade Off Readability Against Compactness and Enforcement
Three serialization formats dominate real-world Kafka usage, and each represents a genuinely different point on the same trade-off curve: how strictly is the shape of the data enforced, how compact is the resulting byte payload, and how easy is it for a human to read the raw bytes without specialized tooling.
JSON — human-readable, loosely typed, no built-in schema
Plain JSON serializes a record as a self-describing text string: field names and values are both present in every single message. This makes it trivially readable in a terminal, easy to debug with general-purpose tools, and requires no code generation or schema file to get started. The cost is everything covered in Part 01 — no enforcement, no compactness, and no compatibility checking unless a team bolts on JSON Schema validation separately, which is uncommon in practice and still does not reduce payload size.
Avro — compact binary, schema travels with (or alongside) the data
Avro is a binary serialization format built specifically with schema evolution in mind — it was designed for exactly the Hadoop and Kafka-style pipelines where producers and consumers deploy independently over long stretches of time. An Avro-encoded record does not repeat field names in every message; the schema (a JSON document describing the record's fields and types) is stored once, separately, and the actual message bytes are a dense binary encoding of just the values, read back out using that schema. This makes Avro payloads significantly smaller than the equivalent JSON, and because every write is validated against a known schema at serialization time, a producer literally cannot emit a record missing a required field or with the wrong type — the serializer throws before the bad record ever reaches the network.
Protobuf — compact binary, schema-first, strong tooling across languages
Protocol Buffers (Protobuf) takes a similar binary, schema-enforced approach to Avro but comes from a different lineage — it was built at Google primarily for RPC and general-purpose service communication, and later became a common choice for Kafka payloads too. A Protobuf schema (a.proto file) is compiled ahead of time into strongly-typed classes for whatever language the producer or consumer is written in, which gives excellent IDE autocomplete and compile-time type checking on both ends, at the cost of a build step that Avro's more dynamic, runtime-schema approach does not always require.
| Format | Payload size | Schema enforcement | Human-readable? | Best fit |
|---|---|---|---|---|
| JSON | Largest — field names repeated every message | None built in; requires bolted-on JSON Schema validation, rarely enforced consistently | Yes, directly | Prototypes, low-volume internal topics, debugging-friendly topics where payload size and strict typing matter less than easy inspection. |
| Avro | Compact — schema stored once, values encoded densely | Strong — every write is validated against a registered schema at serialization time | No — needs the schema to decode | High-volume production pipelines, especially ones already in the Hadoop/Spark/Kafka ecosystem where Avro tooling is mature. |
| Protobuf | Compact — similar to Avro, sometimes smaller for deeply nested structures | Strong — schema-first with generated, strongly-typed code per language | No — needs the .proto schema to decode | Polyglot systems already using Protobuf for gRPC/service communication, or teams that want compile-time-checked generated classes. |
# JSON — every message repeats every field name, every time
{"order_id": "ord_9f21", "customer_id": "cus_4471", "amount_cents": 4999, "currency": "USD"}
# ~90 bytes for 4 small fields, dominated by field name repetition
# Avro — schema (stored once, not per message):
{
"type": "record", "name": "Order",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "customer_id", "type": "string"},
{"name": "amount_cents", "type": "long"},
{"name": "currency", "type": "string"}
]
}
# Avro-encoded message bytes (conceptually): just the values, densely packed,
# no field names repeated -- roughly 30-40 bytes for the same logical record
# at scale (millions of messages/day), this difference is a real network
# and storage cost, not just an academic oneA Schema Registry Is a Central Store That Lets Producers Send an ID Instead of the Schema
Avro and Protobuf both need the reader to have access to the exact schema the writer used, in order to decode the binary bytes back into meaningful fields. Naively, that could mean including the full schema text in every single message alongside the compact binary value — which would defeat the entire purpose of using a compact format in the first place. A Schema Registrysolves this: it is a separate, centrally-run service that stores every schema a topic has ever used, assigns each one a unique ID (and tracks a version history per subject), and lets producers and consumers exchange just that small ID instead of the full schema on every message.
What actually happens on a write and a read
- ✓A producer serializing a record checks whether the schema it is using is already registered for this topic (by content, not just by version number).
- ✓If it is new, the registry validates the new schema against the topic's configured compatibility rule (Part 04) and either accepts it, assigning a new schema ID, or rejects the write outright if it violates the rule.
- ✓The producer prefixes the serialized message with a small header containing that schema ID (a few bytes), followed by the compact binary payload — not the schema itself.
- ✓A consumer reading the message reads the schema ID from the header, fetches (and caches) the corresponding schema from the registry if it does not already have it locally, and uses that schema to decode the rest of the bytes.
- ✓The registry itself is queried rarely per consumer in practice — schemas are small and change infrequently relative to message volume, so client libraries cache aggressively.
[ magic byte ][ 4-byte schema ID ][ Avro-encoded binary payload ]
1 byte 4 bytes the actual compact record bytes
# the schema itself (the JSON schema document) is NEVER sent per-message --
# only ever fetched once per unique schema ID and cached by the client
# producer pseudocode:
schema_id = schema_registry.register_or_get_id('orders-value', order_schema)
message_bytes = magic_byte + encode_int(schema_id) + avro_encode(order, order_schema)
producer.send('orders', value=message_bytes)
# consumer pseudocode:
magic, schema_id, payload = parse_header(message_bytes)
schema = schema_registry.get_schema(schema_id) # cached after first lookup
order = avro_decode(payload, schema)| Concept | What it means |
|---|---|
| Subject | A named scope the registry tracks schema versions under — conventionally <topic>-value or <topic>-key for a given topic. |
| Schema ID | A globally unique identifier assigned the first time a specific schema (by content) is registered — reused if the exact same schema is registered again. |
| Version | A per-subject, incrementing number tracking the schema's evolution history for that subject specifically — distinct from the globally unique schema ID. |
| Compatibility rule | A per-subject setting (Part 04) the registry enforces on every new schema registration — this is what actually prevents a breaking change from being accepted at all. |
Backward, Forward, and Full Compatibility Define What Kind of Change Is Safe
A schema is never final. Fields get added as a business need emerges, fields get removed as they become obsolete, and types occasionally need to change. The entire point of schema evolution rules is to let this happen safely, without requiring every producer and every consumer of a topic to be redeployed in perfect lockstep — which is rarely realistic once a topic has more than one consuming team.
Backward compatibility — new schema, old data
A schema change is backward compatible if a consumer using the new schema can correctly read data written with the old schema. The canonical example is adding a new optional field with a default value: old records simply don't have that field, and a consumer reading them with the new schema fills in the default instead of failing. This is the most common and most useful compatibility mode in practice, because it directly answers the question "can I deploy the new consumer code before every producer has finished migrating?" — yes, as long as the change is backward compatible.
Forward compatibility — old schema, new data
A schema change is forward compatible if a consumer still using the old schema can correctly read data written with the new schema. This matters for the opposite deployment order: "can I deploy the new producer before every consumer has upgraded?" A common way to achieve this is to make sure any newly added field is optional and that removing it (falling back to the old schema's view) doesn't break anything the old consumer relies on.
Full compatibility — both directions at once
Full compatibility requires a change to be both backward and forward compatible simultaneously — new consumers can read old data, and old consumers can read new data. This is the safest and most restrictive mode, and it is what most Schema Registries default to enforcing for shared, multi-consumer topics, because it removes the deployment-ordering question entirely: it does not matter whether producers or consumers upgrade first.
| Compatibility mode | Guarantees | Typical safe changes | Typical unsafe changes |
|---|---|---|---|
| Backward | New schema can read data written with any previous schema. | Adding an optional field with a default; removing a field that had a default (readers fall back to the default). | Removing a required field with no default; changing a field's type incompatibly. |
| Forward | Old schema can read data written with the new schema. | Adding a field that old readers can safely ignore; removing an optional field. | Adding a required field with no default old readers could substitute. |
| Full | Both backward and forward hold at once. | Adding or removing optional fields with defaults, consistently. | Any type change, any required-field addition or removal, any field rename without an alias. |
Why breaking changes break consumers who never touched their own code
This is the detail that catches teams off guard the first time it happens: a consumer can go from working perfectly to crashing in production without a single line of its own code changing, because the producer's schema changed underneath it. If a producer team removes a required field or changes a field's type — genuinely breaking changes — every consumer relying on that field now fails to deserialize or fails on a null value it never expected, purely because of a deploy on the other side of the topic that the consumer team may not have even been aware of.
# Original schema, version 1 (what every existing consumer expects)
{
"type": "record", "name": "Order",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "amount_cents", "type": "long"}, # REQUIRED, no default
{"name": "currency", "type": "string"}
]
}
# Producer team removes "currency" entirely, believing it's unused --
# schema version 2:
{
"type": "record", "name": "Order",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "amount_cents", "type": "long"}
]
}
# A consumer still running against schema v1's generated code tries to
# read a v2 record's "currency" field -- it was required, had no default,
# and simply is not present in the new bytes at all
# -> deserialization fails, or the field resolves to null where the
# consumer's code assumed it could never be null
# -> the consumer team did not change a single line of their own codeThe Same Business Requirement, Done Safely and Done Badly
Consider a concrete scenario: a payments.authorized topic needs to start carrying a new field, payment_method, so downstream fraud-detection and analytics consumers can distinguish card payments from bank transfers. Two ways to make this exact change produce very different outcomes.
Done badly — a required field with no default
# schema v1 (existing, every current consumer expects this)
{
"type": "record", "name": "PaymentAuthorized",
"fields": [
{"name": "payment_id", "type": "string"},
{"name": "amount_cents", "type": "long"}
]
}
# schema v2 -- adds payment_method as REQUIRED, no default value
{
"type": "record", "name": "PaymentAuthorized",
"fields": [
{"name": "payment_id", "type": "string"},
{"name": "amount_cents", "type": "long"},
{"name": "payment_method", "type": "string"} # no default -- BREAKING
]
}
# under BACKWARD compatibility (the common default), the registry REJECTS
# this registration outright: a new-schema reader trying to read an OLD
# record has no value to put in payment_method, and there's no default
# to fall back on -- the registry catches this before it shipsDone safely — an optional field with an explicit default
# schema v2 -- adds payment_method as optional, with an explicit default
{
"type": "record", "name": "PaymentAuthorized",
"fields": [
{"name": "payment_id", "type": "string"},
{"name": "amount_cents", "type": "long"},
{"name": "payment_method", "type": ["null", "string"], "default": null}
]
}
# under BACKWARD compatibility, this registration SUCCEEDS:
# - a new-schema consumer reading an OLD record (no payment_method field
# present) simply gets the default value, null, instead of failing
# - existing producers can keep using schema v1 until they're ready to
# migrate -- nothing forces a synchronized deploy
# - once producers do start populating payment_method, consumers already
# running the new schema pick it up with no further deploy neededThe deployment-ordering flexibility in the safe version is the real payoff. The producer team and every consuming team can each deploy on their own schedule, in any order, without coordinating a synchronized cutover — the registry's compatibility check is what makes that independence provably safe rather than just hopeful. The unsafe version would have forced a fragile, coordinated rollout: every consumer would need to deploy code handling the new field before the producer could safely ship it, and getting that sequencing wrong in a large organization is exactly how a "simple field addition" turns into a multi-team incident.
| Approach | Deployment coordination required | What the registry does |
|---|---|---|
| Required field, no default | Every consumer must deploy support for the field before the producer can ship it — a fragile, manually sequenced rollout. | Rejects the schema under BACKWARD compatibility — the unsafe change never reaches production at all. |
| Optional field with a default | None — producers and consumers can each deploy independently, in any order. | Accepts the schema — old records read fine with the default, new records are read fine by old consumers that simply ignore the new field. |
Practical Guidance: What to Actually Choose, and When
With the mechanics covered, the practical question a team starting a new Kafka project actually faces is simpler than it first appears: default to a schema-enforced binary format with a registry for anything that will outlive a prototype, and reach for plain JSON only when you have a specific, deliberate reason to.
When JSON is still the right call
- ✓A genuinely short-lived prototype or proof of concept, where the schema is still being actively figured out and locking it down would slow down iteration.
- ✓A very low-volume internal topic where payload size is irrelevant and the team explicitly values being able to eyeball raw messages without any tooling.
- ✓Interop with an external system that only speaks JSON and does not support Avro or Protobuf, where converting at the boundary would add more complexity than it removes.
When to reach for Avro or Protobuf, and how to choose between them
For anything with multiple independent consumers, an expected lifetime measured in years rather than weeks, or real throughput, a schema-enforced binary format paired with a Schema Registry should be the default, not an optimization added later after the first breaking-change incident. Between Avro and Protobuf specifically, the deciding factor is usually less about technical superiority — both are compact, both are schema-enforced, both integrate with Confluent-style Schema Registries — and more about ecosystem fit.
| Situation | Lean toward |
|---|---|
| Already using Protobuf for gRPC or other service-to-service communication | Protobuf — reuse the same schemas and generated code, avoid maintaining two parallel schema systems for the same domain objects. |
| Already in a Hadoop/Spark/Kafka-centric data platform, or heavy use of Confluent-ecosystem tooling | Avro — it is the most mature, most default-assumed format across that ecosystem's tooling. |
| Team wants strongly-typed generated code with excellent IDE support across many languages | Protobuf — its code generation and language coverage is generally considered stronger. |
| Schema needs to evolve very dynamically, or be inspected/modified without a compile step | Avro — its schema is a JSON document read at runtime, without requiring generated code to be rebuilt for every consumer. |
| No existing ecosystem preference either way | Either is a reasonable default — the far more important decision is using a schema-enforced format with a registry at all, over plain JSON. |
The single highest-leverage habit this module can leave you with: before merging any change to a schema that already has production consumers, ask explicitly which compatibility guarantee the change needs to preserve, and verify the new schema actually satisfies it — ideally enforced automatically by the registry rejecting an incompatible registration, rather than relying on a reviewer to catch it by eye.
Good Schema Design Is Mostly About Assuming You Will Be Wrong Later
Compatibility rules (Part 04) enforce what changes are technically safe, but they cannot force a team to design a schema that is easy to evolve in the first place. Some initial design choices make almost every future change trivially safe; others make almost every future change a fight against the compatibility checker. A handful of habits, applied from a schema's very first version, pay for themselves many times over across a topic's lifetime.
Default every new field to optional, even when it feels required today
It is tempting to mark a field required because, logically, every record really does have that value at the moment the schema is written. The problem is not the present — it is every future schema version. A field marked required from day one can never later be safely removed under BACKWARD compatibility without a default to fall back to, and a required field forces every new record to populate it forever, even in edge cases the original design never anticipated (a partial or best-effort event, a migration-era record backfilled from an older system that didn\'t track that field at all). Making a field optional with a sensible default, even when today\'s writer always populates it, keeps every future option open at essentially no cost today.
Prefer additive changes over renames, and never repurpose a field
Renaming a field is, from a compatibility checker\'s point of view, indistinguishable from removing the old field and adding an unrelated new one — because that is exactly what it is at the byte level, unless the format explicitly supports field aliases (Avro does, via an aliasesproperty naming the field\'s previous name). The safer default is additive: introduce the new field alongside the old one, migrate producers and consumers onto the new field over time, and only remove the old field once nothing depends on it any more — verified, not assumed. Repurposing an existing field to mean something different (reusing a now-unused legacy_id string field to carry an unrelated new identifier) is worse than a rename: it passes every automated compatibility check, because the field\'s name and type didn\'t change, while silently corrupting the meaning of every consumer still reading it under the old assumption.
# v1: field is customer_id (string)
# team wants to migrate to a structured customer reference instead
# UNSAFE: rename in place
# {"name": "customer_id", ...} -> {"name": "customer_ref", ...}
# passes no compatibility check cleanly; every consumer relying on
# customer_id breaks the moment this ships
# SAFE: additive migration
# v2: add customer_ref alongside the still-present customer_id
{
"fields": [
{"name": "customer_id", "type": "string"}, # kept, unchanged
{"name": "customer_ref", "type": ["null", "CustomerRef"], "default": null} # new, optional
]
}
# producers start populating BOTH fields
# consumers migrate to customer_ref on their own schedule
# only once no consumer depends on customer_id any more (verified via
# schema usage / consumer lag tooling, not assumption) does a LATER
# version safely drop customer_id, now that it's genuinely unusedUse enums and nested records deliberately, not as an afterthought
A field that is logically a fixed set of values (an order status, a payment method) benefits from being modeled as an enum rather than a free-form string, because it gives producers and the schema itself a validated, documented set of legal values instead of relying on every producer to independently agree on spelling and casing. The trade-off is that adding a new enum value is itself a schema change that needs to go through the same compatibility discipline as any other field — a consumer written against an older enum definition may not know how to handle a value it has never seen, so most formats and registries treat unrecognized enum values conservatively (falling back to a default, or a designated "unknown" member) rather than failing outright, which is worth designing for explicitly rather than discovering by accident.
| Design habit | Why it pays off later |
|---|---|
| Make new fields optional with a default, even if always populated today | Preserves the ability to safely remove or reinterpret the field in a future version without breaking BACKWARD compatibility. |
| Prefer additive changes; migrate off old fields gradually instead of renaming in place | Avoids the rename-as-remove-plus-add trap that breaks every consumer still reading the old field name. |
| Never repurpose an existing field for an unrelated new meaning | Repurposing passes automated compatibility checks while silently corrupting meaning for consumers unaware of the change — worse than a change the checker would catch. |
| Model fixed-value fields as enums with an explicit unknown/default fallback | Gives producers a validated set of legal values and gives older consumers a defined, non-crashing behavior when a newer enum value they don't recognize appears. |
| Document the intended meaning of every field directly in the schema (doc strings) | A schema with no documentation forces every new consumer team to reverse-engineer intent from example data, which is how repurposing and misuse happen in the first place. |
The Key and the Value Are Serialized Independently — And Migrations Are Rarely All-at-Once
Everything discussed so far about schemas and formats applies separately to a record's key and its value — Kafka does not require them to use the same serializer, and in practice they very often don't. A topic's value might be a rich Avro record with a dozen fields, while its key is a simple plain string (a customer ID, an order ID) that doesn't need a schema at all. This is normal and intentional, not a sign of an inconsistent setup — the key's only real job is determining partition assignment (covered in the keys and partitioning module) and, for a compacted topic, identifying which records represent the same logical entity, neither of which typically requires the same structural richness as the value.
producer config:
key.serializer = StringSerializer # no schema needed for a simple ID
value.serializer = KafkaAvroSerializer # schema-registry-backed, validated
# a message on this topic, conceptually:
key: "cus_4471" # plain UTF-8 string, no registry involved
value: [magic byte][schema id][avro bytes] # schema-registry-backed binary payload
# the registry, if used for the key at all, tracks a SEPARATE subject:
# orders-key (only relevant if the key itself is a structured Avro/Protobuf object)
# orders-value (the value's schema, evolving independently of the key)Migrating an existing topic from one format to another
A topic already in production, carrying JSON, rarely gets to switch to Avro or Protobuf in one atomic step — existing consumers are still reading the old format, retention means old JSON records are still sitting in the log, and a coordinated all-at-once cutover across every producer and consumer is exactly the kind of fragile, synchronized deploy this module has been arguing against throughout. The practical pattern most teams use instead is a new topic rather than an in-place format change: create a new topic with the new serialization format and registry-enforced schema from the start, have producers dual-write to both the old and new topics for a transition period, migrate consumers onto the new topic at their own pace, and only retire the old topic once nothing is still reading from it.
| Migration approach | What it requires | Risk profile |
|---|---|---|
| In-place format change on the existing topic | Every producer and every consumer must switch formats in the same deploy window. | High — any consumer that misses the cutover breaks immediately and visibly, with no fallback. |
| New topic, dual-write during transition, consumers migrate independently | A transition period where producers write to both topics; some duplicated storage and effort during the overlap. | Low — each consumer migrates on its own schedule, and the old topic keeps working unmodified until every consumer has moved off it. |
Treat the Registry as Infrastructure, With Its Own Review and Deployment Discipline
Everything so far has treated the Schema Registry as a black box that validates and stores schemas correctly. In practice, running one well requires a few operational habits that determine whether it actually prevents incidents or just becomes one more service that can silently drift out of sync with what teams believe is true.
Subject naming strategy
Most registries default to a TopicNameStrategy, where a subject is named <topic>-value (and <topic>-key if the key is also schema-backed) — simple, and correct for the overwhelmingly common case of one record type per topic. Some organizations instead use a RecordNameStrategy, which names the subject after the fully-qualified record type rather than the topic, useful specifically when multiple different record types are intentionally multiplexed onto the same topic (a pattern sometimes used for related event types that need to preserve relative ordering by sharing a partition key). Picking the wrong strategy for a given topic's actual usage pattern is a common early mistake — TopicNameStrategy on a topic that legitimately carries multiple record types ends up fighting the compatibility checker, because it is comparing unrelated record shapes against each other as if they were versions of the same schema.
Schemas as code: CI-enforced review before registration
The registry's own compatibility check is a real, load-bearing safety net, but relying on it as the only review step means a schema change is only ever caught at deploy time, by a producer service failing to start — later and noisier than it needs to be. Mature setups keep schema files in version control alongside application code, run the same compatibility check in CI against the registry before a pull request merges (most registries expose a compatibility-check API endpoint specifically for this), and require the same code review a public API change would get. This turns a schema change into an ordinary, reviewable pull request instead of a runtime surprise discovered when a service fails to boot.
# pseudocode for a CI pipeline step, run on every PR touching a schema file
schema_file = "schemas/orders-value.avsc"
subject = "orders-value"
response = POST /compatibility/subjects/{subject}/versions/latest
body: { "schema": read(schema_file) }
if response.is_compatible == false:
fail_ci("Schema change is NOT compatible with the current registered "
"schema for subject 'orders-value'. See Part 04 for what makes "
"a change backward/forward/fully compatible.")
# the PR cannot merge, let alone deploy, until the schema change
# is fixed -- exactly the same discipline as a failing unit testSchema references for shared, reusable types
Larger schemas often share common sub-structures — an Address record used inside both Order and Customer, say. Copy-pasting that structure into every schema that needs it works initially but guarantees drift over time, as one copy gets updated and the others don't. Both Avro and Protobuf support schema references (or imports) that let one schema depend on another registered schema by reference rather than duplicating its definition, and the registry resolves and validates the whole reference graph together — a change to the shared Address schema is checked for compatibility against every schema that references it, not just checked in isolation.
| Practice | What it protects against |
|---|---|
| Choosing subject naming strategy deliberately (topic-based vs. record-based) | Avoids the compatibility checker comparing unrelated record types as if they were the same evolving schema. |
| CI-enforced compatibility checks before merge | Catches a breaking schema change during code review, not at deploy time when a service fails to start. |
| Schema references for shared sub-structures | Prevents copy-pasted shared types from silently drifting apart across multiple schemas over time. |
| Treating schema files as version-controlled code, not registry-only state | Gives every schema change a reviewable diff and a clear history, rather than relying on the registry's own version history as the only record of what changed and why. |
A CI-Enforced Compatibility Check Is a Safety Net, Not a Design Process
Part 09 covered wiring a compatibility check into CI so a breaking schema change fails a pull request instead of a production deploy. That check answers one narrow question — is this new schema technically compatible with the previous one, according to the configured mode. It does not answer a related and equally important question: does this schema change actually make sense for every consumer that depends on it, in a way pure compatibility math cannot evaluate.
Where automated compatibility checks fall short
A schema change can be technically backward compatible and still be a bad idea. Adding an optional field with a default of 0 to a monetary amount field is compatibility-checker-clean, but if 0 is not actually a meaningful default for that business value — every old record retroactively appears to have a zero amount rather than an unknown one — every consumer that starts trusting the new field will draw wrong conclusions from old data, and the compatibility checker has no way to know that, because it only checks type and structural compatibility, not business meaning.
# technically BACKWARD compatible -- the registry accepts this without complaint
{
"name": "discount_percent",
"type": "double",
"default": 0.0 # passes the compatibility check cleanly
}
# but: old records never HAD a concept of a discount at all -- they were
# written before discounts existed as a feature. A consumer computing
# "average discount across all orders" now silently includes every
# pre-discount-feature order as a literal 0% discount, skewing the
# average toward zero in a way that has nothing to do with actual
# discounting behavior -- the schema change was compatible, but wrongThe fix here is not a stronger compatibility check — no mechanical check can know what a field means to the business. It is a design review step alongside the automated one: when adding a field whose absence in old data is meaningfully different from its default value, either make the field explicitly nullable with a null default (so "unknown/not applicable" is distinguishable from "zero"), or document clearly in the schema and in the change's review that old records should be treated as not having this concept at all, not as having a specific default value for it.
| Question the compatibility checker answers | Question it cannot answer |
|---|---|
| Will an old-schema reader correctly parse a new-schema record, structurally? | Does the chosen default value mean something sensible for records that predate the concept the field represents? |
| Will a new-schema reader correctly parse an old-schema record, structurally? | Is this the right place in the schema for this field, or should it be nested under a more specific sub-record? |
| Does this type change fall within a format's supported promotions (e.g. int to long)? | Will every downstream consumer team actually understand what this field is for without separate documentation or a Slack message? |
Turning This Module Into a Repeatable Checklist
As with delivery semantics, the value of everything covered in this module comes from applying it consistently, not from understanding it once and then defaulting back to habit on the next topic. A short, concrete checklist run before a new topic (or a schema-changing pull request) ships turns this module's reasoning into a repeatable practice rather than something re-derived, inconsistently, by whoever happens to be building the next pipeline.
Before a new topic's first schema is registered
- ✓Is this genuinely a short-lived prototype or a low-volume, low-stakes internal topic where plain JSON is a deliberate, reasoned choice (Part 06) — or is it defaulting to JSON purely out of habit?
- ✓If choosing Avro or Protobuf, does the choice match the team's existing ecosystem (Confluent/Hadoop-adjacent tooling favors Avro; existing gRPC/service-mesh usage favors Protobuf), per Part 06's guidance?
- ✓Is the subject naming strategy (TopicNameStrategy vs. RecordNameStrategy) appropriate for whether this topic carries one record type or intentionally multiplexes several (Part 09)?
- ✓Is the compatibility mode for this subject set explicitly and deliberately — BACKWARD as a sensible default, FULL if the topic is expected to have many independently-deployed consumer teams — rather than left at whatever the registry's own default happens to be?
- ✓Are new fields being added as optional with an explicit, meaningful default from the very first version, per Part 07's design habits, rather than marked required simply because every record happens to have the value today?
Before every subsequent schema change
- ✓Does a CI step actually run the registry's compatibility check against this change before merge, per Part 09 — not just at deploy time?
- ✓For a new field with a default value, does that default represent "this data genuinely does not apply to older records" in a way that will not mislead a consumer computing aggregates or averages over the full history, per Part 10?
- ✓Is this an additive change, or does it rename or repurpose an existing field? If a rename, is it modeled as an additive migration (Part 07) rather than an in-place rename that breaks every reader of the old name?
- ✓If this change is part of a wholesale format migration rather than a field-level evolution, is it following the new-topic-plus-dual-write pattern from Part 08, rather than attempting a synchronized in-place cutover?
## Schema change review — <topic-name> / <subject>
Change type: [ ] new topic, first schema [ ] field addition [ ] field removal
[ ] type change [ ] rename/migration [ ] format migration
Compatibility mode configured: [ ] BACKWARD [ ] FORWARD [ ] FULL [ ] none -- justify:
CI compatibility check passing: [ ] yes [ ] no -- blocking merge until resolved
New/changed field defaults, if any:
field: _______ default: _______
does this default correctly represent "not applicable to old records"? [ ] yes [ ] no
Is this a rename or repurpose of an existing field? [ ] no
[ ] yes -- modeled as additive migration (old + new field coexisting)? [ ] yes [ ] no
Consuming teams notified of this change, if the topic has multiple consumers? [ ] yes [ ] n/aWeigh the Real Cost of Strict Schemas Against What They Actually Buy
It would be easy to close this module concluding that every topic should use a strictly enforced, fully-compatible binary schema from day one, since the failure modes JSON invites (Part 01) are genuinely serious. But schema enforcement is not free, and pretending otherwise leads to the same mistake in the opposite direction as under-investing in schemas — over-engineering a topic that never needed the ceremony in the first place.
Where the real cost shows up
A registered, enforced schema adds a build-time or deploy-time dependency: a producer cannot ship a change without first getting a compatible schema registered, which is exactly the point for a shared, long-lived topic, but is genuine friction for a topic still being actively iterated on by a single small team that owns both ends. Protobuf's code-generation step adds a build pipeline dependency that needs to be kept working across every language a producer or consumer is written in. And operating the registry itself is one more piece of infrastructure with its own availability, monitoring, and access-control requirements — a real, if usually modest, operational cost.
| Investment | Cost | Worth it when |
|---|---|---|
| Schema Registry with enforced compatibility | Deploy-time friction on every schema change; one more service to run, monitor, and secure. | A topic with more than one consumer, or an expected lifetime measured in months or years — the exact conditions Part 06 already argues for. |
| Protobuf code generation across languages | A build pipeline step per language, plus keeping generated code in sync with the .proto source across every consuming service. | A polyglot organization already maintaining this pipeline for gRPC, where the marginal cost of reusing it for Kafka payloads is low. |
| CI-enforced compatibility checks and schema code review (Part 09) | Engineering time to wire up the CI integration and establish the review habit. | Any topic where a breaking change reaching production would be a real incident, not a shrug. |
The practical implication mirrors Part 06's original guidance, stated from the other direction: a genuinely short-lived, single-team, low-stakes topic does not need the full weight of a registry-enforced binary schema, and forcing that ceremony onto it slows down the exact kind of fast iteration such a topic is usually for. The judgment call is not "always enforce" versus "never enforce" — it is matching the investment to the topic's actual blast radius, honestly assessed rather than assumed in either direction.
The Whole Module, Condensed Into One Reference
Every mechanism and rule covered above answers a specific question about how schemas and serialization actually behave in production. It helps to have all of them side by side, as a single reference to check against when scoping a new topic or reviewing a schema change, rather than needing to re-read the full module each time.
| Question | Where it is answered | The short answer |
|---|---|---|
| Why is raw JSON a real production risk, not just a style preference? | Part 01 | It enforces nothing — a renamed field, a changed type, or a missing required field all serialize successfully with no failure signal. |
| How do Avro and Protobuf actually differ from JSON? | Part 02 | Both are compact binary formats validated against a schema at serialization time — a bad write fails at the producer, not silently downstream. |
| What does a Schema Registry actually do? | Part 03 | Stores every schema by ID so messages carry only a small ID header, and enforces a compatibility rule on every new registration. |
| What is the difference between backward and forward compatibility? | Part 04 | Backward: new schema reads old data. Forward: old schema reads new data. Full: both, removing any deployment-order constraint. |
| How does a breaking change reach a consumer that never changed its own code? | Part 04 | The data's shape shifts underneath it via a producer-side schema change — a removed required field or an incompatible type change. |
| What separates a safe schema evolution from an unsafe one, concretely? | Part 05 | Whether a new field is optional with a meaningful default, or required with none — the difference between a rejected and an accepted registration. |
| What design habits keep future schema changes easy? | Part 07 | Default new fields to optional, prefer additive changes over renames, never repurpose a field's meaning. |
| How do keys and values relate to serialization choices? | Part 08 | They serialize independently — a plain-string key alongside a schema-backed value is a common, valid pattern. |
| How should an existing topic migrate to a new serialization format? | Part 08 | A new topic with dual-writing during a transition period, not a synchronized in-place format change. |
| How do you catch a breaking schema change before it ships, not after? | Part 09 | CI-enforced compatibility checks against the registry on every pull request touching a schema. |
| Can a technically compatible change still be a bad idea? | Part 10 | Yes — a default value can be structurally valid and still mislead consumers computing aggregates over data that predates the field's meaning. |
Five Misconceptions About Schemas and Serialization
What This Looks Like on Day One
At Lyft, a driver-location pipeline running on raw JSON for two years accumulates three subtly different producer versions over time, each written by a different team member who didn't realize an earlier field had quietly been renamed from lat tolatitude in one service but not another. A downstream mapping consumer has been silently treating a fraction of location updates as missing coordinates for months, skewing a dashboard nobody was checking closely. Migrating the topic to Avro with a Schema Registry set to BACKWARD compatibility does not fix the historical data, but it makes the specific class of bug that caused it structurally impossible going forward — the registry would have rejected the renamed-field schema the moment it was registered.
At Affirm, a loan-underwriting events topic is deliberately designed from day one with Protobuf and a FULL compatibility mode, because the topic has more than a dozen independent downstream consumers across risk, compliance, and customer-support tooling, each deployed on its own schedule. When the underwriting team needs to add a new risk-signal field, they add it as an optional field with a sensible default and let the registry confirm FULL compatibility before merging — meaning every one of the dozen consuming teams can pick up the new field whenever they get to it, with zero coordination meetings required to sequence a synchronized rollout.
In a system design interview, a candidate proposes JSON for a high-throughput event pipeline shared across five teams "because it's simple and everyone knows it." The interviewer asks what happens when one of those five teams needs to remove a field they believe is unused. The strong answer recognizes this as exactly Part 04's breaking-change scenario: without a schema and a registry enforcing compatibility, that team has no reliable way to know whether another team's consumer actually depends on the field, and the proposed fix is Avro or Protobuf with a Schema Registry set to at least BACKWARD compatibility, so an unsafe removal is rejected automatically rather than discovered the hard way in production.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Kafka stores opaque bytes and enforces nothing about their shape — schema enforcement is entirely the producer and consumer's responsibility, and raw JSON provides none of it by default.
- ✓Avro and Protobuf are compact binary formats that validate every write against a registered schema at serialization time, unlike JSON, which serializes successfully regardless of what changed.
- ✓A Schema Registry lets producers send a small schema ID instead of the full schema on every message, and — more importantly — is the enforcement point that rejects an incompatible schema registration before it ever reaches production.
- ✓Backward compatibility means a new schema can read old data; forward compatibility means an old schema can read new data; full compatibility requires both at once and removes any producer/consumer deployment-ordering constraint.
- ✓A breaking change — removing a required field, changing a type incompatibly — can break a consumer that changed none of its own code, because the data's shape shifted underneath it; a properly configured registry converts that into an immediate, loud producer-side failure instead.
- ✓Default to a schema-enforced binary format with a registry for anything with real longevity or more than one independent consumer, reaching for plain JSON only for short-lived prototypes or genuinely low-stakes, low-volume topics.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.