Summary and key takeaways
The question. Every value must become bytes. This mechanism asks what we turn values into, and what that choice costs. The number of bytes is not a fixed property of the data. It is an output of three decisions made in a fixed order.
The pipeline: three stages, in forced order
- Serialization decides where the description of the data lives (in every record, in a schema, or in a registry). It trades density against self-sufficiency.
- Lightweight (columnar) encoding looks at the values and encodes their distribution, not their declared type. Bytes stay a data structure: comparable, addressable, aggregable.
- General-purpose compression is the last pass. It sees only bytes, finds repetition and skew that no type-aware codec can see, and makes the data opaque.
Reversing the order destroys the type structure that the earlier stages need.
When does compression pay? Only if decode throughput D is larger than the pipe bandwidth B times r/(r − 1), where r is the ratio. The threshold is 2 at r = 2, 1.33 at r = 4, 1.14 at r = 8. It is nearly flat after r ≈ 4, so extra ratio then saves storage money but almost no read time. On object storage (B of 50–100 MB/s) almost any codec passes. On local NVMe (3–7 GB/s), a general-purpose pass that adds r = 1.35 needs about 3.86 times B, which is 12–28 GB/s, and usually fails.
The key facts of each stage
| Stage | Main lesson | Typical number |
|---|---|---|
| Serialization | Avro writes zero framing so it needs a registry; Protobuf pays about 1 tag byte per field and tags are permanent; validation catches structure, never meaning | after ZSTD the formats differ by only 1.3–2× |
| Lightweight encoding | Row order dominates the codec; dictionary, RLE, delta, FOR/PFOR, bit-packing, front coding each bet on one property of the data | RLE: 1× random vs N/k sorted (10,000× for N = 10⁶, k = 100); FOR block of 128 values: 32 bits to 8 bits |
| General-purpose | Redundancy found is bounded by min(window, block); block size sets ratio, read amplification and blast radius; level is a write-path decision | ZSTD level 1 to 19: 50–100× slower to write for about 25% more ratio, same decode speed |
Ten rules of thumb
- Choose the row order first. You get one order per file, and the leading column gets almost all the benefit.
- A maximum is a bad thing for a cost function to depend on. Use PFOR-style patching: size for a percentile, exile the exceptions.
- Delta gives up random access; FOR keeps it. Restart every 64–128 values to buy access back.
- For sorted strings use front coding with a restart about every 16 entries. The number comes from cache behaviour.
- Floats are hard because half their bits are noise. Test whether they are decimals in disguise first (ALP), before using XOR codecs.
- The entropy coder is solved (ANS). Remaining ratio lives in the model, and the last 2× costs about 1000× the CPU.
- ZSTD strictly dominates gzip. Use gzip only where compatibility forces it.
- Compress small, similar records with a trained dictionary, and treat the dictionary as a permanent versioned artifact. It is the third form of the schema registry here.
- Block size from the access pattern (scan: 256 KB–1 MB; seek: 4–16 KB). Level from the read-to-write ratio (level 1–3 at ingest, 9–19 in compaction).
- Checksum every block, compress before encrypting (and never alongside attacker-controlled input), and alert on compression ratio per column.
The impossibility. You cannot have all four at once: minimum bytes, self-sufficient decoding, value-level random access, and in-place update. Serialization gave up self-sufficiency, lightweight encoding gave up updatability, and general-purpose compression gave up random access. Every format is a named position in this impossibility.
Patterns that repeat
- Granularity mismatch creates amplification: here you choose it on purpose (restart interval, page, block).
- A fixed cost sets a natural batch size: block size, restart interval, even the fractional bit in Huffman coding.
- Costs are conserved, only moved: the read-side benefit is paid by a write-side search.
- Variance costs more than volume: 0.8% of values can set 100% of the bit width.
- Immutable data needs a lookup layer and a cleaner: dictionary and fallback, compaction.
- Independence is assumed but rarely true: on English text that assumption costs 4×.
- Validation narrows failures but never removes them: the residue is about meaning (units, enum growth).
- Specialize on the realized distribution, then exile the violations: dictionary fallback, PFOR patches, JIT deoptimization.
- A cost paid by one party and recovered by another is under-provisioned: compression level and schema contracts. The fix is organizational.
- Compression fails by degrading: no error, only a bigger bill later.
What this mechanism hands upward. A byte stream with a fixed decode granularity, a fixed skipping granularity, a known set of pushdown-capable columns, and a known corruption blast radius. Anything built on top can only use what the encoding preserved.
The story in one line. Nikhil at Tradewind learned that "make the files smaller" is not one decision: it is the order of rows, the place of the description, the encoding of each column, and a last pass whose block size decides his latency and his blast radius.