Kafka Serialization and Schemas
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Every record a producer sends and a consumer reads is, as far as Kafka itself is concerned, just two byte arrays — a key and a value. Everything about what those bytes actually mean is a contract the producer and every consumer of that topic have to agree on entirely outside the broker, which never inspects or interprets record contents at all. This page covers that contract: the serializer/deserializer interface, choosing a wire format, and why most non-trivial systems end up wanting a schema registry — deliberately kept brief, since the full worked treatment already lives elsewhere in this documentation.
Serializers and deserializers as a contract
org.apache.kafka.common.serialization.Serializer<T> (serialize(topic, data) → byte[]) and its mirror
Deserializer<T> (deserialize(topic, data) → T) are the only two interfaces Kafka’s client actually cares
about; a KafkaProducer<K, V>/KafkaConsumer<K, V> is configured with one pair for keys
(key.serializer/key.deserializer) and one for values (value.serializer/value.deserializer), and nothing
about the broker, the topic, or Kafka’s own protocol enforces that a given topic’s producers and consumers agree
on which implementation to use — that agreement is entirely the application’s responsibility, and a mismatch
surfaces only at runtime, as a deserialization failure or (worse) as silently wrong data if the mismatched
format happens to decode without throwing.
Bytes, String, JSON, and schema-based formats
| Format | When it fits |
|---|---|
Raw bytes ( |
The application already has a byte representation from elsewhere (a pre-encoded payload, a file’s contents) and doesn’t need Kafka’s client to do any encoding/decoding work at all. |
|
Human-readable, simple values — often the key even when the value uses a richer format, and a common choice
for quick scripts, demos, and the plain |
JSON |
Schema-less and self-describing, so producers and consumers can evolve independently without a shared schema file — at the cost of no compile-time or registry-enforced type safety, larger payloads than a binary format, and no automatic compatibility checking, so a producer can silently start sending a shape consumers don’t expect. |
Avro / Protobuf / JSON Schema |
A schema describes the payload’s structure up front (Avro’s |
Deprecated/removed constructs aside, the choice here is rarely about raw throughput alone: it’s about how much enforcement an organization wants on the producer/consumer contract, and how many independently-deployed consumers a given topic actually has — a handful of services sharing one team’s deployment cadence can get away with JSON’s looser contract far longer than a topic with dozens of downstream consumers across different teams.
Headers
Every Kafka record also carries an independent list of key/value headers
(ProducerRecord.headers()/ConsumerRecord.headers(), each entry a String key and a byte[] value),
alongside the key and value payloads — metadata that doesn’t belong in the payload’s own schema, such as a
trace ID, a content-type hint, a schema-registry-aware converter’s own bookkeeping (Kafka Connect’s converters
use headers this way, per Kafka Connect), or the error context a
Kafka Streams dead-letter-queue handler attaches, covered on
Kafka Streams. Headers are serialized independently of whatever
serializer is configured for the key/value, so adding one never requires touching the payload’s schema.
The case for a schema registry
A schema registry stores every schema version a topic’s payloads have ever used, assigns each one an ID, and — critically — rejects a new schema version outright if it breaks the compatibility rule configured for that
subject, before a producer using it is allowed to publish. A schema-aware serializer looks the current schema
up (or registers a new one) against the registry at send time and writes only its ID on the wire instead of the
full schema text, which a schema-aware deserializer resolves back by looking that ID up in turn — keeping every
record’s payload compact regardless of how large the schema itself is. Two schema registries are in common use
with Kafka, and it’s worth being explicit that neither is an Apache Kafka component: Confluent Schema
Registry and Apicurio Registry are both separately maintained, third-party projects that happen to be the
de facto standard tooling for this job, not something kafka-clients or the broker ships or requires.
Compatibility modes and key-vs-value schema decisions
The compatibility modes a registry subject can be configured with (BACKWARD, FORWARD, FULL, their
_TRANSITIVE variants, and NONE) are exactly the ones
API-First: Messaging already covers in full, including the
REST API used to set them per subject — they are not specific to a Spring Boot application and apply equally to
a plain kafka-clients producer/consumer pair. One decision worth calling out here: a topic’s key schema and
its value schema are registered, versioned, and checked for compatibility completely independently of each
other (as separate registry subjects, conventionally <topic>-key and <topic>-value) — a key is usually kept
far simpler than the value (often just a primitive String/long/UUID rather than a full record type, since it
only needs to drive partitioning and rarely needs to evolve), and it is entirely normal, and often preferable,
for a key not to be schema-registered at all even when the value is.
For the complete picture — registering schemas via the avro-maven-plugin, wiring a schema-registry-aware
serializer/deserializer into a Spring Boot KafkaTemplate/@KafkaListener, and documenting the resulting topic
contract with AsyncAPI — see API-First: Messaging, which this
page deliberately does not repeat.