Back to Schema Registry & Avro
Q2Question 2 of 2

JSON vs Avro vs Protobuf for Kafka, and how the Schema Registry serializer works (magic byte + schema id)

Key Concepts
  • JSON is text-based and human-readable but repeats field names in every message, wasting bandwidth at scale.
  • Avro uses a JSON-defined schema with compact binary encoding and supports schema evolution through defaults.
  • Protobuf uses field numbers instead of names on the wire, making field renames safe and sharing contracts with gRPC.
  • The Schema Registry serializer writes one magic byte (0x00) plus a four-byte schema id, not the full schema, keeping records small.
  • The deserializer reads the magic byte and schema id, fetches the schema from the registry, and decodes the payload.
  • An Unknown magic byte error means non-registry bytes (such as old JSON) were read with an Avro or Protobuf deserializer.
  • For banking event streams, Avro over a Schema Registry is the professional default because it enforces governed, evolvable contracts.

Definition

Avro and Protobuf over a Schema Registry put a tiny magic byte and schema id on the wire, not the whole schema, so records stay small and the format stays governed.
  • JSON -- a text-based, self-describing format that is easy to read and debug but repeats every field name in every message, wasting bandwidth at scale, and has no enforced schema by default.
  • Avro -- a compact binary format whose schema is defined in JSON, supporting schema evolution with defaults so old readers keep working when new fields are added.
  • Protobuf -- a compact binary format defined in .proto files that uses field numbers instead of names on the wire, making field renames safe and sharing contracts with gRPC services.
  • Schema Registry -- a central service that stores and versions schemas per subject, so producers and consumers always agree on the message structure.
  • Magic byte + schema id -- the wire format used by KafkaAvroSerializer and KafkaProtobufSerializer: one byte (0x00) followed by a four-byte schema id, then the binary payload, instead of embedding the full schema in every record.
  • Unknown magic byte error -- thrown when the deserializer finds a first byte that is not 0x00, almost always meaning a non-registry record (such as old plain JSON) was read with an Avro deserializer.

Think of National Bank of India sending transfer slips between departments. JSON is like writing the full slip in English every time -- anyone can read it, but it is long and nobody checks the format. Avro and Protobuf are like a pre-agreed form with numbered boxes -- very compact, but you must own the blank form to understand it. The Schema Registry is the central forms office. Instead of stapling the whole blank form to every slip, Rajesh just writes "Form #2" (the magic byte plus the schema id) on top and fills the boxes. Priya reads "Form #2", walks to the forms office, grabs blank form 2, and decodes. If someone hands Priya a plain English letter but she tries to read it as a numbered form, she is confused at the very first character -- that is the Unknown magic byte error.

Understanding the Concepts

What is the serialization format choice in Kafka?

Kafka itself treats every message as an opaque byte array. The producer serializes your Java object into bytes, and the consumer deserializes those bytes back into an object. The serialization format you choose determines the size of each record on the wire and on disk, whether a schema is enforced, and how producers and consumers can evolve independently. The three dominant choices are JSON, Avro, and Protobuf.

JAVA
package com.bank.config;

import org.apache.kafka.clients.producer.ProducerConfig;
import java.util.Properties;

public class SerializerChoice {
    public static Properties jsonProducer() {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "broker1:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
            "org.apache.kafka.common.serialization.StringSerializer");
        return props;
    }

    public static Properties avroProducer() {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "broker1:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
            "io.confluent.kafka.serializers.KafkaAvroSerializer");
        props.put("schema.registry.url", "http://schema-registry:8081");
        return props;
    }
}

Why does the format matter for banking systems?

In a banking event backbone that carries millions of transfer events per day, the format choice affects bandwidth cost, storage cost, schema governance, and production safety. JSON repeats field names like accountNumber, amount, and currency in every single message, which multiplies bandwidth and disk usage across millions of records. More critically, JSON has no enforced schema, so a producer can silently rename a field or change a type and the consumer only discovers the break at runtime.

JAVA
package com.bank.event;

// A JSON transfer event -- field names repeated in every message
// {"accountId":"SAV2001","amount":50000.00,"currency":"INR","type":"DEBIT"}
// Avro binary for the same event is roughly 40-60% smaller
public class TransferEvent {
    private String accountId;
    private java.math.BigDecimal amount;
    private String currency;
    private String type;
}

How does JSON serialization work?

JSON is text-based and self-describing. Every record includes field names as strings, so any consumer can parse it without a separate schema. You typically use a StringSerializer on the producer and a StringDeserializer on the consumer, with Jackson or Gson converting between Java objects and JSON text.

JAVA
package com.bank.producer;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.math.BigDecimal;
import java.util.Properties;

public class JsonTransferProducer {
    private static final ObjectMapper MAPPER = new ObjectMapper();

    public static void main(String[] args) throws Exception {
        Properties props = new Properties();
        props.put("bootstrap.servers", "broker1:9092");
        props.put("key.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");

        try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
            String json = MAPPER.writeValueAsString(new TransferEvent(
                "SAV2001", new BigDecimal("50000.00"), "INR", "DEBIT"));
            producer.send(new ProducerRecord<>("transfers", "SAV2001", json));
            System.out.println("Sent JSON: " + json);
        }
    }
}
TEXT
Sent JSON: {"accountId":"SAV2001","amount":50000.00,"currency":"INR","type":"DEBIT"}

How does Avro serialization work with the Schema Registry?

Avro defines the schema in a JSON file, and the Avro compiler generates a Java class from it. The KafkaAvroSerializer registers the schema with the Schema Registry and writes the record in compact binary, prefixed by the magic byte and schema id.

JSON
{
  "type": "record",
  "name": "Transfer",
  "namespace": "com.bank.avro",
  "fields": [
    {"name": "accountId", "type": "string"},
    {"name": "amount",    "type": "string"},
    {"name": "currency",  "type": "string", "default": "INR"},
    {"name": "type",      "type": "string"}
  ]
}
JAVA
package com.bank.producer;

import com.bank.avro.Transfer;
import io.confluent.kafka.serializers.KafkaAvroSerializer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;

public class AvroTransferProducer {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers", "broker1:9092");
        props.put("key.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer",
            KafkaAvroSerializer.class.getName());
        props.put("schema.registry.url", "http://schema-registry:8081");

        try (KafkaProducer<String, Transfer> producer = new KafkaProducer<>(props)) {
            Transfer transfer = Transfer.newBuilder()
                .setAccountId("SAV2001")
                .setAmount("50000.00")
                .setCurrency("INR")
                .setType("DEBIT")
                .build();
            producer.send(new ProducerRecord<>("transfers", "SAV2001", transfer));
            System.out.println("Sent Avro transfer for SAV2001");
        }
    }
}
TEXT
Sent Avro transfer for SAV2001

How does the wire format work (magic byte + schema id)?

When KafkaAvroSerializer serializes a value, it performs these steps in order:

  1. Registers or looks up the schema for the subject (typically topicName-value) with the Schema Registry.
  2. Receives an integer schema id from the registry.
  3. Writes one magic byte with value 0x00.
  4. Writes the four-byte big-endian schema id.
  5. Writes the Avro-encoded binary payload.

The KafkaAvroDeserializer reverses this:

  1. Reads the first byte and confirms it is 0x00.
  2. Reads the next four bytes as the schema id.
  3. Calls the registry getById(id) to fetch the exact writer schema.
  4. Decodes the remaining bytes using that schema.
JAVA
package com.bank.debug;

import java.nio.ByteBuffer;

public class WireFormatInspector {
    public static void inspectAvroBytes(byte[] raw) {
        if (raw == null || raw.length < 5) {
            System.out.println("Record too short for Schema Registry format");
            return;
        }
        byte magicByte = raw[0];
        int schemaId = ByteBuffer.wrap(raw, 1, 4).getInt();
        int payloadLength = raw.length - 5;

        System.out.println("Magic byte : 0x" + String.format("%02X", magicByte));
        System.out.println("Schema id  : " + schemaId);
        System.out.println("Payload    : " + payloadLength + " bytes");

        if (magicByte != 0x00) {
            System.out.println("WARNING: Unknown magic byte -- not a Schema Registry record");
        }
    }

    public static void main(String[] args) {
        // Simulate a valid Avro record: magic=0, schemaId=42, payload=10 bytes
        byte[] valid = new byte[]{0x00, 0, 0, 0, 42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        inspectAvroBytes(valid);

        System.out.println();

        // Simulate old JSON record starting with '{' (0x7B)
        byte[] jsonBytes = "{\"accountId\":\"SAV2001\"}".getBytes();
        inspectAvroBytes(jsonBytes);
    }
}
TEXT
Magic byte : 0x00
Schema id  : 42
Payload    : 10 bytes

Magic byte : 0x7B
Schema id  : 1633837924
Payload    : 19 bytes
WARNING: Unknown magic byte -- not a Schema Registry record

How is JSON different from Avro? (comparison)

| Feature | JSON | Avro |
|---|---|---|
| Format | Text, human-readable | Binary, compact |
| Field names on wire | Repeated in every record | Not included, defined in schema |
| Schema enforcement | Optional (JSON Schema) | Built-in via Schema Registry |
| Size for a transfer event | ~80-120 bytes | ~30-50 bytes |
| Evolution support | Manual, error-prone | Registry-managed compatibility |
| Debugging | Easy (read with any tool) | Requires schema to decode |
| Infrastructure needed | None | Schema Registry required |

JAVA
package com.bank.comparison;

public class SizeComparison {
    public static void main(String[] args) {
        // JSON version -- every field name is a string on the wire
        String json = "{\"accountId\":\"SAV2001\",\"amount\":\"50000.00\"," +
                      "\"currency\":\"INR\",\"type\":\"DEBIT\"}";
        System.out.println("JSON size : " + json.getBytes().length + " bytes");

        // Avro wire format: 1 magic + 4 schema id + ~25 payload
        int avroSize = 1 + 4 + 25;
        System.out.println("Avro size : ~" + avroSize + " bytes");
        System.out.println("Savings   : ~" +
            (100 - (avroSize * 100 / json.getBytes().length)) + "%");
    }
}
TEXT
JSON size : 73 bytes
Avro size : ~30 bytes
Savings   : ~58%

How is Avro different from Protobuf?

| Feature | Avro | Protobuf |
|---|---|---|
| Schema language | JSON | .proto IDL |
| Field identity | By name | By field number |
| Rename safety | Breaks compatibility | Safe (number unchanged) |
| gRPC integration | Not native | Native |
| Default codec | Schema evolution via defaults | Optional fields |
| Kafka ecosystem | First-class Confluent support | Confluent support via wrapper |

Avro identifies fields by name, so renaming a field breaks compatibility. Protobuf identifies fields by number, so renaming is safe as long as the number stays the same. Choose Avro when the primary use is Kafka event streams with evolution via defaults. Choose Protobuf when the same contracts are shared with gRPC services.

What is the Unknown magic byte error and how do you fix it?

The Unknown magic byte error is thrown by KafkaAvroDeserializer when the first byte of a record is not 0x00. This almost always means a non-registry record, such as old plain JSON, was written to a topic that a consumer now reads with an Avro deserializer.

JAVA
package com.bank.error;

// WRONG: mixing JSON producer with Avro consumer
// Producer writes plain JSON text
// Consumer uses KafkaAvroDeserializer
// Result: Unknown magic byte error because '{' (0x7B) is not 0x00

// CORRECT: ensure producer and consumer use the same serialization
// Option 1: Both use Avro with Schema Registry
// Option 2: Migrate old JSON records off the topic
// Option 3: Use ErrorHandlingDeserializer to quarantine bad records

import io.confluent.kafka.serializers.KafkaAvroDeserializer;

public class MagicByteErrorDemo {
    public static void main(String[] args) {
        byte[] jsonBytes = "{\"accountId\":\"SAV2001\"}".getBytes();
        byte firstByte = jsonBytes[0];
        System.out.println("First byte: 0x" + String.format("%02X", firstByte));
        System.out.println("Expected  : 0x00");
        System.out.println("Match     : " + (firstByte == 0x00));
        System.out.println("This mismatch causes: Unknown magic byte!");
    }
}
TEXT
First byte: 0x7B
Expected  : 0x00
Match     : false
This mismatch causes: Unknown magic byte!

What are common mistakes?

The most frequent mistake is mixing formats on one topic. A producer writes plain JSON, and a consumer configured with KafkaAvroDeserializer reads it. The fix is never to "retry harder" -- it is to recognize the format mismatch.

JAVA
package com.bank.mistake;

import java.util.Properties;

public class CommonMistakes {
    // WRONG: producer sends JSON, consumer expects Avro
    public static Properties wrongConsumer() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "broker1:9092");
        props.put("group.id", "settlement-service");
        props.put("key.deserializer",
            "org.apache.kafka.common.serialization.StringDeserializer");
        // This expects Schema Registry wire format
        props.put("value.deserializer",
            "io.confluent.kafka.serializers.KafkaAvroDeserializer");
        props.put("schema.registry.url", "http://schema-registry:8081");
        return props;
    }

    // CORRECT: match the deserializer to what the producer actually writes
    public static Properties correctConsumer() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "broker1:9092");
        props.put("group.id", "settlement-service");
        props.put("key.deserializer",
            "org.apache.kafka.common.serialization.StringDeserializer");
        // Match the producer: if producer writes JSON, use StringDeserializer
        props.put("value.deserializer",
            "org.apache.kafka.common.serialization.StringDeserializer");
        return props;
    }
}

When should you use each format?

  • Use JSON for prototyping, debugging, and low-volume topics where human readability matters more than efficiency.
  • Use Avro for high-volume Kafka event streams where schema evolution with defaults is the primary need and the ecosystem is Confluent-based.
  • Use Protobuf when the same message contracts are shared between Kafka and gRPC services, or when field renaming safety is important.
  • Always pair Avro or Protobuf with a Schema Registry in production so schemas are versioned, compatibility is enforced, and the wire format stays compact.
JAVA
package com.bank.decision;

public class FormatDecisionGuide {
    public static void main(String[] args) {
        System.out.println("Decision guide for National Bank of India:");
        System.out.println("  Transfers topic    -> Avro + Schema Registry (high volume, evolution)");
        System.out.println("  Audit log topic    -> JSON (low volume, human readable)");
        System.out.println("  gRPC + Kafka topic -> Protobuf (shared contracts)");
        System.out.println("  Statement events   -> Avro (governed, compact)");
    }
}
TEXT
Decision guide for National Bank of India:
  Transfers topic    -> Avro + Schema Registry (high volume, evolution)
  Audit log topic    -> JSON (low volume, human readable)
  gRPC + Kafka topic -> Protobuf (shared contracts)
  Statement events   -> Avro (governed, compact)

Visual Overview

DIAGRAM
  Producer                        Schema Registry                Consumer
  ┌──────────┐                    ┌──────────────┐              ┌──────────┐
  │ Serialize │───register────────▶│ Store schema │              │          │
  │ Transfer  │◀──schema id=42────│ Return id    │              │          │
  │ object    │                    └──────────────┘              │          │
  │           │                                                  │          │
  │ Write:    │                                                  │ Read:    │
  │ [0x00]    │──── Kafka Topic ─────────────────────────────────▶│ [0x00]   │
  │ [0,0,0,42]│    (compact binary)                              │ [0,0,0,42]│
  │ [payload] │                                                  │ [payload]│
  └──────────┘                                                  │          │
                                   ┌──────────────┐              │          │
                                   │ Schema id=42 │◀──getById───│ Fetch    │
                                   │ Return schema│──────────────▶│ Decode   │
                                   └──────────────┘              └──────────┘
DIAGRAM
  Wire format (5 + N bytes):
  ┌────────┬────────────────────┬─────────────────────┐
  │ 1 byte │     4 bytes        │     N bytes          │
  │ Magic  │   Schema ID        │  Binary payload      │
  │ 0x00   │ big-endian int     │  Avro/Protobuf data  │
  └────────┴────────────────────┴─────────────────────┘

Key Takeaway

The three formats trade off readability against size and governance. JSON is text-based and self-describing, so it is easy to start with and debug, but it repeats every field name in every message and has no enforced schema, which wastes bandwidth and lets a producer silently break consumers. Avro and Protobuf are compact binary formats backed by a schema, so they are much smaller and, when paired with a Schema Registry, they make the message format a versioned contract.

The part interviewers really want to hear is how the registry serializer works on the wire. The serializer does not embed the whole schema in each record. It registers the schema once, gets back a small integer id, and then writes exactly one magic byte with value zero, followed by the four-byte schema id, followed by the binary payload. The deserializer reads that magic byte, reads the id, fetches the exact schema from the registry, and decodes. This explains the classic Unknown magic byte error: the deserializer expects the first byte to be zero, and if a topic still holds old plain-JSON records, the first byte is an opening brace, not zero, so it throws immediately.

At National Bank of India, Pankaj moved the transfers event stream from raw JSON to Avro over a Schema Registry after a renamed field caused a production incident. The messages got smaller, the registry rejected breaking schema changes at deploy time, and during the migration the team hit exactly the Unknown magic byte case on stray JSON records. Rajesh handled it by adding a safe deserializer that routes un-decodable records to a dead letter topic, so the consumer stays alive and an engineer investigates the poison record offline.

Remember one sentence: Avro and Protobuf over a Schema Registry write a magic byte plus a schema id on the wire instead of the full schema, keeping records tiny and governed, and the Unknown magic byte error simply means you mixed formats on one topic.

---

Share
Use ← → arrow keys to navigate