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.
- 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
defaultvalue 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:
user_id:101username:"alice"email:"[email protected]"created_at:1711958400000
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"));
}
}
}
}
- To compare Avro with Parquet and ORC formats, read Deep Dive into File Formats.
- To dive into high-performance columnar storage for analytics, read Deep Dive into Apache Parquet.
- To see how Kafka streams Avro data in production, read Big Data Processing Tools.