8 min read

Deep Dive into Apache Avro: Streaming and Schema Evolution

As modern data architectures shift towards real-time event streaming architectures (like Apache Kafka), engineering teams need a data serialization format that is incredibly fast to write, extremely compact over the wire, and capable of safely evolving schemas without breaking downstream consumers. Apache Avro is the standard answer to these constraints.

In this technical deep dive, we explore Avro's internal architecture, binary encoding efficiency, and how it solves the complex problem of dynamic schema resolution.


1. Architecture & File Layout

Unlike Apache Parquet (which is optimized for scanning specific columns across millions of rows), Avro is a row-oriented format. This makes it ideal for streaming workloads where applications need to append entire records (events) to a topic as fast as possible.

The Avro Object Container File (OCF)

When Avro data is saved to disk (such as in an AWS S3 data lake landing zone), it is packaged into an Object Container File. Avro is famously "self-describing", meaning the schema required to read the data is literally embedded at the top of the file.

Header Block Offset 0 Metadata • 4-byte Magic: 'Obj\1'    • JSON Schema Definition    • 16-byte Sync Marker (Unique File UUID) Data Block 1 Splittable Chunk • Row Count (Varint)    • Serialized Compressed Binary Rows    • 16-byte Sync Marker (Boundary) Data Block 2 Splittable Chunk • Row Count (Varint)    • Serialized Compressed Binary Rows    • 16-byte Sync Marker (Boundary) Data Block N... Workers Seek by 16-Byte Sync Marker • Parallel Worker Chunks    • Arbitrary File Offsets    • 16-byte Sync Marker (End of Block)
  • The Header: Contains a JSON definition of the schema and a randomly generated 16-byte sync marker.
  • Sync Markers: Avro files are "splittable." Frameworks like Apache Spark or Hadoop can assign different chunks of an Avro file to different worker nodes. The workers look for the 16-byte sync marker to confidently determine where a new data block begins, ensuring parallel processing.

2. Binary Encoding & Efficiency

Avro achieves incredibly high compression and fast serialization speeds through two core architectural design choices.

No Field Names in Records

In JSON, every single record duplicates the field keys. If you stream one million JSON events, you transmit the strings "user_id" and "transaction_amount" one million times.

Avro solves this by completely stripping field names from the payload. Because the schema is guaranteed to be known by the reader, Avro only serializes the raw bytes in the exact order specified by the schema. This drastically cuts the size of the payload.

Variable-Length Encoding (Zigzag)

Avro utilizes variable-length integer encoding (varints) combined with Zigzag encoding for signed integers. If you store the number 5 in a standard 64-bit integer format, it consumes 8 bytes on disk (mostly leading zeros). Avro compresses small integers into just 1 or 2 bytes, only expanding to larger byte sizes if the actual value requires it.


3. Schema Evolution & Resolution

In a distributed microservice architecture, the team producing data (the Writer) and the team consuming data (the Reader) operate independently. What happens if the Writer adds a new field or deletes an old one? Avro handles this gracefully through Dynamic Schema Resolution.

When reading an Avro message, the library compares the Writer's Schema against the Reader's Schema:

  • Missing Fields: If the Writer included a field the Reader doesn't know about, the Reader simply skips over those bytes.
  • Added Fields: If the Reader expects a field that the Writer didn't provide, the Reader automatically fills it with the default value defined in its schema.

The Confluent Schema Registry

While an Avro OCF file embeds the schema in its header, embedding the schema inside every single Kafka message would destroy the network bandwidth. This is solved using the Schema Registry.


sequenceDiagram
    autonumber
    actor Producer as Event Producer
    participant SR as Schema Registry
    participant Kafka as Kafka Broker
    actor Consumer as Event Consumer

    Producer->>SR: 1. Register / Validate Schema (JSON)
    SR-->>Producer: 2. Return Unique Schema ID (e.g. 104)
    Producer->>Kafka: 3. Publish Message: [Magic Byte | Schema ID: 104 | Packed Binary]
    Kafka->>Consumer: 4. Stream Message Byte Payload
    Consumer->>SR: 5. Cache Check & Fetch Schema by ID (104)
    SR-->>Consumer: 6. Return Writer Schema (JSON)
    Consumer->>Consumer: 7. Binary Schema Resolution & Deserialization

Instead of attaching a 2KB JSON schema to a 50-byte event, the Producer strips the schema out and registers it with the Schema Registry. The Producer then attaches a tiny 5-byte header to the Kafka message (1-byte magic marker + 4-byte Schema ID). The Consumer reads the ID, fetches the schema from the registry, caches it, and deserializes the payload.


4. Embedded Schema vs Schema Registry

Here is a complete comparison showing how an Avro record looks with an embedded schema (as an Avro Object Container File) versus with a Schema Registry (commonly used in streaming systems like Kafka).

4.1. Avro Object Container File (Embedded Schema)

In a standalone .avro file, the full JSON schema is written directly into the file header.

The Schema (user.avsc)

{
  "type": "record",
  "name": "UserEvent",
  "namespace": "com.example",
  "doc": "User registration event",
  "fields": [
    { "name": "user_id", "type": "long" },
    { "name": "username", "type": "string" },
    { "name": "email", "type": ["null", "string"], "default": null },
    { "name": "created_at", "type": "long" }
  ]
}

Physical File Layout

+-----------------------------------------------------------------------------------+
| HEADER                                                                            |
|                                                                                   |
| Magic Bytes:  'O' 'b' 'j' \x01                                                    |
| Metadata:     {"avro.schema": "{\"type\":\"record\",\"name\":\"UserEvent\",...}"} |
| Codec:        "snappy"                                                            |
| Sync Marker:  0xA3 0xF1 0x82 0x99 ... (16 random bytes)                           |
+-----------------------------------------------------------------------------------+
| DATA BLOCK 0                                                                      |
|                                                                                   |
| Record Count: 2                                                                   |
| Block Size:  48 bytes                                                             |
| Records:     [Binary payload of Record 1][Binary payload of Record 2]             |
| Sync Marker:  0xA3 0xF1 0x82 0x99 ... (Matches Header)                            |
+-----------------------------------------------------------------------------------+

How the Data Values are Encoded (Binary)

For a record with values:

Avro writes only the raw value bits, using variable-length integer encoding (Zigzag) and UTF-8 string prefixes:

[CA 01]                       <-- user_id: 101 (Zigzag/Varint encoded)
[0A] [61 6C 69 63 65]         <-- username: length 5, string "alice"
[02]                          <-- email (Union discriminator index 1 = string, 0 = null)
[22] [61 6C 69 ... 63 6F 6D]  <-- email value: length 17, string "[email protected]"
[80 A2 C0 90 A2 50]           <-- created_at: 1711958400000 (Zigzag/Varint encoded)

[!TIP] Key takeaway: The record is self-describing because the file header carries the complete JSON schema text.

4.2. Avro with Confluent Schema Registry (Kafka Style)

When publishing millions of small messages to Kafka, attaching the full JSON schema to every 100-byte message would waste massive amounts of network bandwidth and storage.

Instead, the Schema Registry extracts the schema out of the payload.

The Wire Protocol Wireframe

With Schema Registry, every Kafka message payload follows a strict 5-byte wire protocol header:

Byte 0:          Magic Byte (0x00) - Identifies Confluent wire format
Bytes 1-4:       Schema ID (4-byte Big-Endian Integer) -> e.g., ID 42 = 0x00 0x00 0x00 0x2A
Bytes 5 to End:  Raw Avro Binary Payload (Exact same value encoding as above)

Byte-Level Message Breakdown

If Schema ID = 42 (0x0000002A), the exact bytes sent over the network look like this:

00 00 00 00 2A CA 01 0A 61 6C 69 63 65 02 22 61 6C ... 80 A2 C0 90 A2 50
|  |---------| |-----------------------------------------------------|
|       |                                 |
|       |                                 +--> Binary Avro Record Values ONLY
|       +--> Schema ID (42 in hex)             (No field names, no metadata)
+--> Confluent Magic Byte

Side-by-Side Comparison

Feature Standalone File (.avro) Streaming with Schema Registry
Schema Location Embedded inside file header JSON Centralized in HTTP Schema Registry DB
Header Overhead Several Hundred Bytes (JSON text) 5 Bytes (Magic Byte + 4-byte Schema ID)
Payload Size Larger (Good for files, bad for stream) Hyper-compact (Ideal for high-throughput events)
Schema Lookup Read top of file once Producer & Consumer fetch once by ID and cache in memory
Primary Use Batch ingestion, S3/HDFS storage Kafka, Pulsar, real-time microservice messaging

5. Avro in High-Throughput Trading Environments

In high-throughput event streaming (like Apache Kafka or Pulsar) and ultra-low-latency financial trading environments, performance is measured in microseconds and gigabytes per second.

In these systems, JSON creates massive performance bottlenecks across CPU, Network, and Memory. Avro (or similar binary formats like Protobuf) is preferred for five critical structural reasons:

5.1. Minimal Payload Size & Bandwidth Consumption

  • JSON: Verbose. Every single message repeats string field names, brackets, quotes, and punctuation over the wire.
    Example: {"order_id": 987654, "symbol": "AAPL", "price": 182.50, "quantity": 100} → ~75 Bytes.
  • Avro (with Schema Registry): Contains zero field names or structural characters in the message. It sends a 5-byte header (1 byte magic + 4-byte Schema ID) followed by packed raw binary bits.
    Same payload in Avro → ~18 Bytes (a 75%+ reduction in wire size).

[!TIP] Trading Impact: Smaller payloads mean vastly higher message throughput per second on network interfaces (NICs) and lower packet buffer saturation.

5.2. Microsecond-Level Serialization / Deserialization (CPU Overhead)

Text parsing is extremely CPU-expensive compared to binary decoding:

  • JSON: To process a JSON payload, the CPU must scan string by string, match characters, handle whitespace, allocate string objects, and parse ASCII numbers back into integers/floats using expensive string-to-number functions (atof/atoi).
  • Avro: Data types are fixed-width or varint-encoded at precise bit offsets. The CPU performs simple direct byte/bit-shifting and memory-copy operations without string parsing or intermediate memory allocation.

[!TIP] Trading Impact: Parsing binary payload values takes orders of magnitude fewer CPU clock cycles than parsing JSON strings, directly reducing tick-to-trade latency.

5.3. Native Schema Enforcement at the Producer Level

  • JSON: Schema-less by default. A broken producer can send {"price": "ONE_HUNDRED"} or drop a critical field. The downstream order matching engine won't notice until it crashes or fails at runtime.
  • Avro: Strongly typed with strict schema compilation. A producer cannot physically serialize a message if it violates the data type contract.

[!TIP] Trading Impact: Prevents malformed messages or corrupt data from entering high-speed matching engines, eliminating catastrophic downstream runtime exceptions.

5.4. Zero-Copy Processing & Memory Footprint

  • JSON: Creating and parsing JSON requires allocating thousands of temporary string objects in memory for keys and values, triggering frequent Garbage Collection (GC) pauses in languages like Java or Go.
  • Avro: Uses compact, contiguous byte arrays that can be processed with zero-copy mechanisms (passing memory pointers directly) without triggering GC overhead or memory fragmentation.

[!TIP] Trading Impact: Eliminates non-deterministic Garbage Collection stalls, keeping latency long-tails (P99 / P99.9) consistently low.

5.5. Safe Protocol Evolution in Live Pipelines

In fast-moving trading systems, order schema fields change frequently (e.g., adding exchange execution flags or microsecond timestamp precision).

  • JSON: Schema changes are untracked. Upstream producers might drop or change fields, silently breaking downstream risk, compliance, or analytics systems.
  • Avro: Built-in Schema Evolution rules guarantee that new producers can emit updated data formats while legacy consumers continue reading messages without breaking.

Summary Comparison

Metric JSON in Streaming / Trading Avro (with Schema Registry)
Parsing Cost High CPU (ASCII text string parsing) Extremely Low (Direct byte/bit manipulation)
Payload Overhead High (Repeats field strings every message) Minimal (5-byte header + raw bitstream)
Garbage Collection Impact High (Allocates many string/key objects) Very Low (Reusable byte buffers)
Data Integrity Weak (Runtime type errors) Strong (Compile-time / Serialization-time checks)
Latency Profile High P99 latency spikes Predictable microsecond-level latency

6. Avro vs. Parquet vs. JSON

Understanding when to use Avro requires comparing it against the other industry standards.

Format Orientation Best Use Case Pros Cons
JSON Row Web APIs, Configs Human readable, zero setup, ubiquitous. Massive payload bloat, slow parsing, no type safety.
Apache Avro Row Event Streaming (Kafka) Blazing fast writes, extremely compact payloads, dynamic schema evolution. Binary format (unreadable without schema), poor at analytical column scans.
Apache Parquet Columnar Data Warehouses / Analytics Unbeatable analytical scan speeds, extreme compression. Very slow to write (requires buffering), complex internal structure.

The Golden Rule of Data Engineering: Stream your real-time data across Kafka using Avro to ensure fast, safe writes. Once the data lands in your Data Lake, run a compaction job to convert those Avro row-files into Parquet column-files for blazing-fast analytical queries.


7. Apache Avro Java Example

An Apache Avro example in Java demonstrating schema definition, writing (serialization), and reading (deserialization) data using the GenericRecord API.

Maven Dependency

<dependency>
    <groupId>org.apache.avro</groupId>
    <artifactId>avro</artifactId>
    <version>1.11.3</version>
</dependency>

Complete Java Example

import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericDatumReader;
import org.apache.avro.generic.GenericDatumWriter;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.file.DataFileReader;
import org.apache.avro.file.DataFileWriter;
import org.apache.avro.io.DatumReader;
import org.apache.avro.io.DatumWriter;

import java.io.File;
import java.io.IOException;

public class AvroExample {

    public static void main(String[] args) throws IOException {
        // 1. Define JSON Schema
        String schemaJson = "{"
            + "\"type\": \"record\","
            + "\"name\": \"User\","
            + "\"namespace\": \"com.example\","
            + "\"fields\": ["
            + "   {\"name\": \"name\", \"type\": \"string\"},"
            + "   {\"name\": \"favorite_number\", \"type\": [\"int\", \"null\"]},"
            + "   {\"name\": \"favorite_color\", \"type\": [\"string\", \"null\"]}"
            + " ]"
            + "}";

        Schema schema = new Schema.Parser().parse(schemaJson);

        // 2. Create generic records matching the schema
        GenericRecord user1 = new GenericData.Record(schema);
        user1.put("name", "Alice");
        user1.put("favorite_number", 256);
        user1.put("favorite_color", "blue");

        GenericRecord user2 = new GenericData.Record(schema);
        user2.put("name", "Bob");
        user2.put("favorite_number", 7);
        // favorite_color left as null

        File file = new File("users.avro");

        // 3. Serialize records to an Avro file
        DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(schema);
        try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
            dataFileWriter.create(schema, file);
            dataFileWriter.append(user1);
            dataFileWriter.append(user2);
        }
        System.out.println("Data serialized to " + file.getAbsolutePath());

        // 4. Deserialize records back from the Avro file
        DatumReader<GenericRecord> datumReader = new GenericDatumReader<>(schema);
        try (DataFileReader<GenericRecord> dataFileReader = new DataFileReader<>(file, datumReader)) {
            System.out.println("\nReading records from file:");
            while (dataFileReader.hasNext()) {
                GenericRecord user = dataFileReader.next();
                System.out.println("Name: " + user.get("name") 
                        + ", Favorite Number: " + user.get("favorite_number")
                        + ", Favorite Color: " + user.get("favorite_color"));
            }
        }
    }
}

Related Guides:
← PreviousDeep Dive into Apache ORCNext →Deep Dive into Apache Hudi