Understanding Avro: the file format built for moving data

Correct at time of publishing. For up-to-date performance and feature information, see rugo.dev.

The first rocket launch in our dataset, Sputnik-1, takes 234 bytes as a line of JSON and 96 bytes as Avro. Nothing in those 96 bytes says what a field is called or where it ends, and any program that holds the schema can still read it back exactly. That trade, a schema in exchange for bytes, is most of what Avro is.

Avro started in 2009 inside the Hadoop project, and it has kept the job it was built for: moving records between systems that are written, deployed and upgraded separately. It's what you meet in event streams, in message queues, and in the landing zone where raw data arrives before it's tidied into something else.

In an earlier post we put Avro next to Parquet and JSON Lines. This one opens Avro up. We'll take a single record, then a file, then a schema change, and look at the bytes each time. The data is the same 4,630 orbital launch attempts, and the file is in the rugo.dev repository if you want to follow along.

It starts with a schema

Every Avro file is described by a schema, and the schema is plain JSON. Here's the one for our launches, trimmed to its shape:

{
  "type": "record", "name": "Launch",
  "fields": [
    {"name": "Company",    "type": "string"},
    {"name": "Price",      "type": ["null", "double"]},
    {"name": "Lauched_at", "type": ["null", {"type": "long", "logicalType": "timestamp-micros"}]},
    {"name": "Rocket",     "type": {"type": "record", "name": "Rocket", "fields": [
      {"name": "Name",     "type": "string"},
      {"name": "Status",   "type": {"type": "enum", "name": "RocketStatus",
                                    "symbols": ["Active", "Retired"]}}
    ]}},
    {"name": "Mission",    "type": "string"}
  ]
}

There are two layers of types. The base layer is small: null, boolean, int, long, float, double, bytes and string, plus the containers record, enum, array, map, union and fixed. On top sit logical types: a name that says what a stored value means. A timestamp-micros is a long counting microseconds. A decimal is bytes. Readers that know the logical type hand you a timestamp or a decimal, and readers that don't still get the long or the bytes.

Two of those types carry more weight than they look. A union is how Avro says "or", and a union with null is how it says "optional": ["null", "double"] is a price that may be missing. A record can hold other records, so nested data keeps its shape instead of being flattened into Rocket_Name and Rocket_Status.

What 96 bytes say

Here is the first record in the file, cut out of the first block and read byte by byte.

The first launch record, 96 bytes, split into its eight fields with the hex bytes and meaning of each: Company is length 9 then RVSN USSR, Price is branch 0 which is null, Launched_at is branch 1 then a long, and so on. The same launch is 234 bytes as a JSON Lines row.

Read down the table and the whole encoding is there. A string is its length, then its UTF-8 bytes. The 12 in front of "RVSN USSR" is 9, the length, written the way Avro writes every integer (the next section explains that). The union in Price is a single byte, 00, which names branch 0, the null, and has no value after it. In Lauched_at the branch is 02, branch 1, and a long follows. The enum is the position of its symbol: 02 is 1, which is "Retired".

What isn't there is as important. There are no field names, because the schema has them. There are no field numbers or type tags, which is where Avro parts ways with formats like Protocol Buffers: the fields appear in the order the schema lists them, and that order is the only index. And there are no delimiters. Every length comes first, so a reader never scans for a quote or a comma.

The cost of that is stated plainly in the spec: you can't read the data without the schema it was written with. Everything else about Avro, from the file header to schema registries, is a way of making sure the schema arrives with the bytes.

Small numbers stay small

Integers are where the saving is easiest to see. Avro writes every int and long with the same two ideas, and both appear in the record above.

Zigzag encoding maps 0, -1, 1, -2, 2 to 0, 1, 2, 3, 4. Varint writes seven bits per byte with the top bit meaning another byte follows: 20 becomes the single byte 28, -1 becomes 01, and 300 becomes the two bytes d8 04.

Zigzag deals with the sign. A negative number written the usual way sets its top bits and needs every byte, so Avro interleaves the signs instead: 0, -1, 1, -2, 2 become 0, 1, 2, 3, 4. A number near zero stays near zero whichever side it's on. Varint then writes it seven bits at a time, with the top bit of each byte saying whether another byte follows. Anything from -64 to 63 fits in one byte, -8,192 to 8,191 in two, and so on, so a number only ever takes the bytes it needs.

That's why a string's length costs one byte, why the nullable price costs one byte when it's missing, and why a row with a lot of small counters and flags comes out so much smaller than its text. A double is the exception: it's always eight bytes, just as a float is always four.

A file is a header, then blocks

A single record isn't a file. The Avro container file wraps a stream of records so that it can be read, split and appended to.

An Avro file is a header followed by blocks. The sample file is 112,717 bytes: a 658-byte header holding 4 bytes of magic, a 603-byte schema, the deflate codec name and a 16-byte sync marker, followed by 29 blocks. Each block is a record count, a size, the compressed records and the sync marker.

The header opens with four bytes, Obj and a version, then a small map of metadata: the schema, under the key avro.schema, and the codec, under avro.codec. It ends with a 16-byte random sync marker. In our file the schema is 603 of the header's 658 bytes, and the header is 0.6% of the file, which is why carrying the schema with the data is affordable.

After the header come the blocks, and every block has the same four parts: how many records it holds, how many bytes it takes, the records themselves, and the sync marker again. fastavro, which wrote this file, fills a block with about 16 KB of records, compresses it, and starts the next. Our 4,630 launches land in 29 blocks of 109 to 172 records, each taking 3 to 5 KB on disk.

Compression happens one block at a time

The codec named in the header applies to every block. The spec requires two, none (which the spec spells null) and deflate, and allows others: Snappy, zstd, bzip2 and xz are the common ones.

The same 4,630 records written with six codecs. No compression 463 KB; snappy 146 KB, 3.2 times smaller; zstd 120 KB, 3.9 times smaller; bzip2 113 KB, 4.1 times smaller; deflate 113 KB, 4.1 times smaller; xz 99 KB, 4.7 times smaller.

Before any compression, the Avro file is already 2.4× smaller than the JSON Lines one: 463 KB against 1.09 MB. That's the encoding at work, keys and quotes and text numbers gone. Deflate then takes it another 4.1× down to the 113 KB file in the repository. Snappy gives up some of that size for speed, xz gives up speed for size, and deflate is the one every Avro library must be able to read, which is why it's the safe default for files other people will open.

Because each block is compressed on its own, the codec never has to look outside it. Any block can be inflated without its neighbours, and that's what makes the next two properties possible.

Splitting and appending

The sync marker is a boundary you can find without parsing. A reader dropped into the middle of a file scans forward until it sees the 16 bytes, and from there it's on a block edge and can read normally.

A file drawn as 29 blocks separated by orange sync markers, divided into three shares for three workers. Worker 1 starts at byte 0 and reads the header. Workers 2 and 3 start at their byte offsets, scan to the next sync marker, and read blocks until their share ends.

That makes an Avro file splittable. A query engine can cut a large file into byte ranges, give each range to a different worker, and trust every worker to find its first whole block. It also makes the file appendable. A writer reads the header for the schema, codec and sync marker, then adds new blocks after the last one. No footer has to be rewritten, because there isn't one.

Schema evolution

Here is the reason Avro is the format between services. Producers and consumers upgrade on different days, and the data written by one version has to be readable by another.

Avro's answer is to give a reader two schemas: the writer's, which came out of the file's header, and the reader's, which is the shape your code wants today. The reader resolves one against the other by field name.

A writer schema with Company and Price, and a reader schema that adds Crewed as a boolean defaulting to false. The reader gets Company and Price from the file and Crewed from the default. Allowed changes: add a field with a default, drop a field, rename with an alias, widen a number. Not allowed: add a field with no default, change string to int.

The rules are short. A field the reader wants and the file has is read. A field the reader wants and the file doesn't have takes its declared default. A field the file has and the reader doesn't want is skipped. Numbers can widen, from int up to long, float and double, and a field can be renamed if the new schema lists the old name as an alias. What can't happen is a field with no default appearing from nowhere, which is why give every new field a default is the one rule to remember.

Nothing in the file changes. The same bytes serve every reader schema, and all the work happens when the file is read. In rugo it's one argument:

from rugo import avro

reader_schema = {
    "type": "record", "name": "Launch",
    "fields": [
        {"name": "Mission", "type": "string"},
        {"name": "Price",   "type": ["null", "double"], "default": None},
        {"name": "Crewed",  "type": "boolean", "default": False},  # not in the file
    ],
}

with avro.read_avro("space_missions.avro", reader_schema=reader_schema) as reader:
    for morsel in reader:
        print(morsel.column_names, morsel.column("Crewed").to_pylist()[:3])

The file was written before Crewed existed. All 4,630 rows come back with it set to False, and the fields the reader schema leaves out are dropped.

What Avro doesn't do

Everything above comes from storing rows. The cost is the flip side of it. An Avro block holds a record count and a byte size, and that's all it says about its contents. There are no minimums and maximums, no per-field offsets, and no index.

Three records drawn to the width of their fields. Company, Price and Mission_Status are highlighted as kept; the other five fields are shown as walked past. A reader must inflate every block and decode each record far enough to find the next field.

So a reader that wants three fields of eight still inflates every block and walks every record. A string can be hopped over by its length. A number can't, because its size is in its own top bits, so the reader reads until it finds the end. A good reader never turns the unwanted fields into values, and that's a large saving. But it can't skip a block, because nothing says the block can be skipped.

That is the line between Avro and Parquet, and it's a fair one. Avro is built for writing one record at a time and for reading whole records. When the question is "how many launches did SpaceX fly?" over a billion rows, a columnar format wins by not reading the answer's neighbours. When the job is carrying each launch from one system to the next, Avro wins by not caring.

Avro without a file

Most Avro that crosses a network isn't a file at all. A message on a queue is one record, and repeating a 603-byte schema in front of it would cost more than six times the record. So systems send just the record and put the schema somewhere both ends can find it.

The common pattern is a schema registry. A producer registers its schema and gets an ID back, then writes each message as a short prefix (in Confluent's wire format, a zero byte and a four-byte schema ID) followed by the Avro bytes. A consumer reads the ID, fetches the writer schema from the registry once, caches it, and resolves it against its own reader schema. It's the same two-schema idea as the file, with the registry standing in for the header. Avro also defines a self-contained single-object encoding, a marker and an eight-byte fingerprint of the schema ahead of the record, for the same purpose.

When to use Avro

And when the data settles in storage and gets queried a few columns at a time, compact it into Parquet. The two formats hand off to each other: Avro at the edge, Parquet at rest.

Where rugo fits

In Python, the usual route into an Avro file is a reader that builds an object for every record. rugo reads Avro blocks into typed columns instead, and only the columns you ask for.

from rugo import avro

# Nested fields are addressed with a dot: "Rocket.Name"
with avro.read_avro("space_missions.avro", columns=["Company", "Rocket.Name", "Price"]) as reader:
    for morsel in reader:
        print(morsel.column_names)
        print(morsel.column("Rocket.Name").to_pylist()[:2])

That prints the three column names and ['Sputnik 8K71PS', 'Sputnik 8K71PS']. Avro has no statistics, so there's no predicates argument as there is for Parquet; you filter the values you get back. Logical types arrive decoded, enums as their symbols, and the same morsel feeds the Parquet writer when it's time to compact.

Reading every field of 200,000 rows, rugo is 7.8× faster than fastavro and 50× faster than Apache's own avro package. Called from Python it also beat Apache Arrow's Rust Avro reader in three of four races. The home page has the measurements, with versions and hardware.

Try it

pip install rugo is the whole install. The Avro notebook runs in your browser on the same 4,630 launches: columns, nested fields, reader schemas, and a conversion to Parquet.

Reproducing this

Every byte count comes from docs/data/space_missions.avro, parsed with nothing but the spec: the 658-byte header, the schema's 603 bytes, the 29 blocks and their record counts and sizes. The 96-byte record is the first record of the first block, inflated and split using the schema. The 234 bytes is the first line of space_missions.jsonl, which flattens the rocket into two fields where Avro nests it. The codec sizes come from writing the same 4,630 records with fastavro 1.13 at each codec's defaults, so the deflate figure (112,731 bytes) differs from the repository file (112,717) by a few bytes. The reader-schema example was run with rugo 0.5.4.

The diagrams are generated by tools/build_avro_diagrams.py, which parses the Avro file directly.