Columnar files, Arrow and schema resolution
Why is Arrow not a competitor to Parquet?
Columnar file encodings
People usually say a columnar file is good because of its column layout. That is imprecise. There are three separate wins, and you can measure them one by one, because they have different sizes in different situations.
1. Projection. Bytes read scale with (columns touched ÷ columns in the table). On a 200-column table where the query touches 3 columns, this alone is about 200 ÷ 3 ≈ 67×. On a 5-column table where the query touches all 5, it is 1×. It depends only on table width and query shape.
2. Encoding homogeneity. A column chunk has one type and one distribution. So type-aware encodings apply, and LZ matching is not interrupted by fields of other types. This is typically 2–4×, and it applies even when you read every column. That is why a columnar file beats a row file even on a narrow table where the projection argument predicts a tie.
3. Skipping. Per-chunk statistics (such as minimum and maximum) let you avoid decoding some chunks entirely. How well this works depends almost completely on clustering (Lightweight compression).
And there is a structural loss: to rebuild one full row you must touch every column chunk. Parquet is poor at point lookups for a reason no tuning can fix. The file's granularity is a column chunk, the query's granularity is a row, and the mismatch causes amplification.
Nested and repeated data: definition and repetition levels
Columnar layout assumes a fixed number of values per row per column. Repeated and optional fields break this. For the path user.phones[].number, a row may have zero, one or nine values. If you flatten everything into one buffer, you lose (a) which row each value belongs to, and (b) the difference between absent and empty.
The method from Google's Dremel paper uses two small integers per value.
- The definition level counts how many of the optional or repeated ancestors along the path are actually present. If it is lower than the maximum, the value is absent, and the level itself says at which depth it went missing: was
usernull, wasphonesan empty list, or wasnumbernull inside a present phone? A simple null bit cannot make this distinction. That is why nested columnar formats need something richer than a null bit. - The repetition level says at which repeated ancestor this value started a new list. Zero means "a new record starts here".
The elegant point is the bound: both levels are limited by the depth of the schema, not by the size of the data. The typical maximum is 3 or 4, so each level fits in 2 or 3 bits. Both sequences are highly repetitive, so run-length encoding reduces them to almost nothing. An arbitrary tree costs a fraction of a byte per value.
The cost is reconstruction: assembling full records needs a state machine that runs over all the columns in step. Projection queries are cheap. Retrieving whole documents is not.
The footer as an out-of-band description
Parquet has a hierarchy that is best read as three different fixed-cost decisions:
- the row group (about 128 MB): the unit of parallelism; it must fit in the writer's memory;
- the page (about 1 MB): the unit of compression, and therefore the unit of decode granularity;
- the footer: holds the schema and the statistics.
The footer is at the end of the file. This has a consequence that people feel but blame on the wrong cause. On object storage you must read the tail of the file to learn the footer's length, then read the footer, then read data. That is two round trips of 30–70 ms before the first data byte, often reduced to one by a speculative read of the tail. For a 1 GB file this is noise. For ten thousand files of 100 KB it is the entire job. (Check: 10,000 × 2 × 50 ms = 1,000 s of waiting, if run one after another.) The small-files problem is partly a serialization decision. It is not only a metadata-service problem, and no catalog tuning removes these round trips.
Arrow, and why it is not a competitor to Parquet
Parquet minimizes bytes at rest. Arrow minimizes cycles at use. (Apache Arrow is a standard in-memory column format.) Different goals give different designs, and asking "which is better?" is a category error. The interesting question is what the conversion between them costs. Almost nobody measures it.
Arrow's constraints, and what each one forces:
A value must be usable by the CPU with no decode step. So fixed-width values sit at offset i × width. No varints, no bit-packing. Random access is O(1) (constant time), and arithmetic can use SIMD. The price: Arrow data in memory is often 2–10× larger than the Parquet file it came from. That is not a defect. It is the space-versus-time trade taken to the opposite end.
Nulls must not cost a branch. So Arrow uses a separate validity bitmap (one bit per value saying "present or not") instead of sentinel values or a nullable union type. Let us derive the cost. One bit on an int64 column is 1 ÷ 64 = 1.56% extra space. It removes a data-dependent branch whose misprediction costs about 15 cycles, while the arithmetic itself costs about 1 cycle. You compute over all values without checking, and apply the mask afterwards. Counting nulls becomes popcount (a CPU instruction that counts 1-bits), 64 values per instruction. You could pay eight times that space and still come out ahead. A sentinel value, by contrast, takes one value away from the domain and brings the branch back. That is why designs that use sentinel nulls keep losing.
Variable-length data needs O(1) access. So Arrow uses an offsets buffer: element i occupies the bytes from offsets[i] to offsets[i+1]. Length is derived instead of stored, so one buffer is enough instead of two. The price: 4 or 8 bytes per element even for empty strings, no appends out of order, and a 32-bit offset limit that forces the "Large" variants once data passes 2 GB.
SIMD must apply cleanly. So buffers are 64-byte aligned and padded to multiples of 64 bytes: the size of a cache line and the width of an AVX-512 register. Here this mechanism reaches straight down to the hardware and lets hardware geometry decide a wire format.
Folklore to correct: "zero-copy" means no serialization step, not no transfer. Arrow Flight (a network protocol for Arrow data) still moves bytes over a network like anything else. What disappears is the encode and decode CPU at both ends. In a typical path from Parquet to a dataframe through a row-oriented database driver, that CPU is 60–80% of total time. Quote the saving you really get, not the one the phrase suggests.
Schema fingerprinting and writer-reader resolution
Bytes were written under schema W (the writer's). The reader wants them as schema R. Resolution is a function of this pair. Avro's version of resolution works like this:
- fields are matched by name;
- a field missing in the data is filled from R's default value;
- a field present only in W is discarded;
- widening promotions are allowed: int → long → float → double.
This needs both schemas at read time, no exception, because of the zero-framing decision made earlier. So W must be recoverable: either embedded in a file header (where it costs nothing per record) or referenced by a few bytes (where it cannot be embedded).
Fingerprinting is content-addressing applied to a schema. The hash is computed over a canonical form: whitespace, the order of fields in the JSON text, documentation strings and aliases are removed. Identity must be semantic, not textual. (Two schemas that differ only in a comment must have the same fingerprint.)
Collision check. For n schemas and a 64-bit fingerprint, the birthday bound puts the chance of any collision at about n² ÷ 2^65: about 2^−32 at roughly 92,000 schemas, and about 0.5 only at four billion. No real organization registers anywhere near that many schemas, so collisions are not the part that breaks.
What does break is subtler:
- Backward compatible: the new reader can read data from old writers.
- Forward compatible: the old reader can read data from new writers.
A registry set to be non-transitive checks each new version only against the previous version. A chain of individually valid changes can then be invalid across two steps. Example: version 2 adds a field with a default; version 3 removes a different field. A consumer pinned at version 1 can now fail on a topic that the registry approved at every step. Who pays? Whoever replays from the start of the topic, six months later, in another team. (This is Nikhil's first incident.)
And now the deeper limit, which is the real lesson of this article:
Type compatibility can be checked by a machine. Meaning compatibility cannot: a field that changes units (grams to kilograms: Nikhil's second incident) passes every compatibility check ever written, and so does an enum that gains a member. Why every structural check is blind to meaning, and what to do instead, is schema evolution's hard boundary.