Apache Avro

Avro is a compact, schema-based binary format designed for data whose shape changes over time. This page explains how schemas and the binary encoding work, how reader and writer schemas are reconciled, and how Avro is used with Kafka and a schema registry.


1. What Avro Is

Apache Avro is a data serialization system that came out of the Hadoop project. It defines three things: a schema language (written in JSON) that describes the shape of a record, a compact binary encoding for records that follow a schema, and an object container file format that stores many records together with the schema that wrote them.

The key design choice is that Avro binary data contains no field names and no type tags. A record of {"id": 42, "name": "Ada"} is encoded as a variable-length integer followed by a length-prefixed string, nothing else. That makes it small and fast, but it also means you cannot decode the bytes without the schema that wrote them. Avro solves this by always keeping the writer's schema close to the data: in the file header, or by reference through a schema registry.

2. Why It Matters

Data that lives longer than the code that produced it will eventually be read by programs that expect a different shape. Avro is built around that fact. Its schema resolution rules let a reader with a newer (or older) schema decode data written with a different version, as long as the changes follow compatibility rules. This is why Avro is the most common format for event streams on Kafka and for row-oriented landing zones in data lakes: producers and consumers can be upgraded independently.

Avro is row-oriented, so it suits write-heavy and record-at-a-time workloads: append-only logs, message payloads, and RPC. For analytical scans that read a few columns out of many, a columnar format such as Parquet is usually a better fit; it is common to land data as Avro and compact it into Parquet later.

3. How It Works

An Avro Object Container File Header magic Obj\x01 schema JSON + codec Data block 1 count + byte size binary records Data block 2 count + byte size binary records Data block 3 count + byte size binary records Each block ends with the 16-byte sync marker from the header, so a reader can split the file anywhere.

A schema is a JSON document. Primitive types are null, boolean, int, long, float, double, bytes and string. Complex types are record, enum, array, map, union and fixed. Logical types layer meaning on top: date is an int of days since the epoch, timestamp-millis is a long, decimal is bytes or fixed with a precision and scale.

Encoding is straightforward. Integers and longs use zig-zag variable-length encoding, so small magnitudes take one byte. Strings and bytes are a length followed by the raw bytes. A record is just its fields concatenated in schema order. A union is written as the index of the chosen branch followed by the value, which is how optional fields work: ["null","string"] writes 0 for null or 1 followed by the string.

The container file in the diagram starts with a header holding the magic bytes, metadata (including the full schema and the compression codec such as deflate, snappy or zstandard), and a random 16-byte sync marker. Records are grouped into blocks; each block is optionally compressed and ends with the sync marker. Because the marker is known, a distributed engine can start reading in the middle of a file, scan forward to the next marker, and process blocks in parallel. That is what makes Avro files splittable even when compressed.

4. Schema Evolution

Schema Resolution: Writer Schema vs Reader Schema Writer schema v1 id: long name: string Resolution rules match fields by name fill defaults, skip extras Reader schema v2 id, name email: [null,string] = null bytes record Old data read with the new schema: email is missing, so its default (null) is used.

When a reader decodes data it holds two schemas: the writer schema (what the bytes actually contain) and its own reader schema (what the application wants). Avro walks both in parallel. Fields are matched by name (aliases can map renamed fields). A field in the writer but not in the reader is skipped. A field in the reader but not in the writer takes the reader's default; if no default exists, resolution fails. Some type promotions are allowed, for example int to long, float or double, and string to bytes.

From these rules come the practical compatibility modes used by schema registries:

Renaming a field, changing a type incompatibly, or adding a required field with no default are breaking changes. The usual remedy is a new field plus a migration period, or a new topic.

5. Worked Example

The script below defines version 1 of a schema, writes a container file with the fastavro library, then reads it back with a version 2 reader schema that adds an optional email field. It runs with pip install fastavro.

from fastavro import writer, reader, parse_schema

v1 = parse_schema({
    "type": "record", "name": "Customer", "namespace": "shop",
    "fields": [
        {"name": "id",   "type": "long"},
        {"name": "name", "type": "string"},
    ],
})

v2 = parse_schema({
    "type": "record", "name": "Customer", "namespace": "shop",
    "fields": [
        {"name": "id",    "type": "long"},
        {"name": "name",  "type": "string"},
        {"name": "email", "type": ["null", "string"], "default": None},
    ],
})

rows = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Grace"}]
with open("customers.avro", "wb") as f:
    writer(f, v1, rows, codec="deflate")

with open("customers.avro", "rb") as f:
    for rec in reader(f, reader_schema=v2):
        print(rec)
# {'id': 1, 'name': 'Ada', 'email': None}
# {'id': 2, 'name': 'Grace', 'email': None}

The file header stores v1, so the reader knows exactly what the bytes contain and fills email from the default. If v2 had declared email as a plain string with no default, the read would fail, which is precisely the kind of change a registry in backward mode would reject.

6. Avro with Kafka

Avro with Kafka and a Schema Registry Producer Avro serializer Schema Registry subject: orders-value Kafka topic orders Consumer Avro deserializer 1. register / get id 2. [0][id][bytes] 3. read 4. fetch schema by id Messages carry a 5-byte prefix (magic byte + 4-byte id) instead of the full schema.

Embedding a full schema in every Kafka message would waste far more bytes than the payload itself. The common pattern (popularised by the Confluent Schema Registry, and also offered by Apicurio and AWS Glue Schema Registry) is to store schemas centrally. The producer's serializer registers the schema under a subject (by default <topic>-value), receives a numeric id, and writes a small header followed by the Avro bytes. The consumer reads the id, fetches the schema once, caches it, and decodes with its own reader schema.

The registry also enforces the compatibility mode per subject, so an incompatible change is rejected at deploy time rather than discovered as a consumer crash at 3 a.m.

# pip install "confluent-kafka[avro]"
from confluent_kafka import Producer
from confluent_kafka.schema_registry import SchemaRegistryClient
from confluent_kafka.schema_registry.avro import AvroSerializer
from confluent_kafka.serialization import SerializationContext, MessageField

schema_str = open("customer_v2.avsc").read()
sr = SchemaRegistryClient({"url": "http://localhost:8081"})
ser = AvroSerializer(sr, schema_str)
p = Producer({"bootstrap.servers": "localhost:9092"})

value = {"id": 3, "name": "Linus", "email": None}
p.produce("customers",
          value=ser(value, SerializationContext("customers", MessageField.VALUE)))
p.flush()

7. Avro vs Alternatives

FormatLayoutSchemaEvolutionBest for
AvroRow, binaryJSON schema travels with data or by registry idRich reader/writer resolution by field nameKafka events, row landing zones, Hadoop-era pipelines
Protocol BuffersRow, binary.proto compiled to codeBy field number; add optional fields, never reuse numbersgRPC services, typed APIs
JSONRow, textOptional (JSON Schema)Ad hoc; consumers must be tolerantHuman-readable APIs, low volume
ParquetColumnar, binaryEmbedded in footerAdd columns; engine-dependentAnalytical scans, lakehouse tables
ORCColumnar, binaryEmbeddedSimilar to ParquetHive-centric warehouses

A rough rule: use Avro or Protobuf on the wire, and Parquet (often under a table format such as Iceberg or Delta) at rest for analytics.

8. Pitfalls

10. Further Reading