Chapter 11 — Key Takeaways (File Formats and Serialization)
The page for a format decision, and for reading somebody else's benchmark.
The two questions
1. Who reads it, and how?
a human, occasionally ─────────────────▶ CSV / JSON
a machine, whole records ──────────────▶ AVRO (+ registry) / Protocol Buffers
a machine, few columns of many rows ───▶ PARQUET (ORC if Hive-centric)
2. Where does the schema live? — this determines when you find out something is wrong
| Schema lives | Mismatch surfaces |
|---|---|
| Nowhere (CSV) | at read time, in a consumer, months later |
| In each record (JSON) | at read time, per record |
| In the file header/footer (Avro, Parquet) | at read time, immediately and clearly |
| In a registry (Avro + registry) | at write time — the producer fails |
Splittable = can be divided at arbitrary boundaries and processed in parallel. A whole-file gzipped JSON/CSV is not, so it uses one core regardless of cluster size. Inside Parquet, compression is per column chunk — splittable whatever the codec.
Text formats
CSV leaves seven properties unspecified — delimiter, quoting, escaping, embedded newlines, encoding, null representation, date format. Three consumers → seven failures, one per property. Interchange format, never internal.
JSON's disqualifying property: an array is one value, so a 4 GB file is parsed end-to-end before any record is available. JSON Lines fixes exactly this — splittable, appendable, and a corrupt line costs one record rather than a file.
Avro on the wire, Parquet in storage
Kafka: AVRO + registry ──▶ landing: JSONL / JSON-in-Parquet ──▶ silver/gold: PARQUET + zstd
row, evolving, validated row, raw, preserved columnar, typed
at WRITE time
Avro's schema-evolution specification is what makes a registry possible (Ch. 17). It is row-oriented, so reading one field of a million records reads all fields of all million.
Schema-on-read vs. schema-on-write, by layer
| Layer | Approach | Why |
|---|---|---|
| Bronze | schema-on-read | you cannot lose what you did not reject |
| Silver | schema-on-write | enforce the contract, fail loudly at the boundary |
| Gold | schema-on-write | consumers depend on stability |
The measurement — 300k Kestrel clickstream events
| Format | Size | b/event | vs. JSON |
|---|---|---|---|
| JSON Lines | 242.68 MB | 809 | 1.0× |
| CSV | 117.55 MB | 392 | 2.1× |
| JSON Lines + gzip | 23.22 MB | 77 | 10.4× |
| Parquet + snappy | 20.35 MB | 68 | 11.9× |
| CSV + gzip | 19.93 MB | 66 | 12.2× |
| Parquet + zstd, sorted | 12.99 MB | 43 | 18.7× |
| Parquet + zstd | 12.56 MB | 42 | 19.3× |
Three findings, none predictable
1. Gzipped CSV beat Parquet + snappy. Columnar's advantage is not primarily compression — general-purpose compressors capture much of the same redundancy. The codec mattered more than the layout: zstd was 1.6× smaller than snappy.
2. Sorting made it 3.4% WORSE. The events already arrived in timestamp order, which delta-encodes almost perfectly; sorting by session scattered them.
Sorting helps when the data has no useful order and hurts when it destroys one it already had. For append-only event data, sort by time — which usually means do nothing. Use Z-ordering for a secondary access pattern, and measure.
3. The ratio is a property of the DATA.
| Generator | JSON b/ev | Parquet b/ev | Ratio |
|---|---|---|---|
| Synthetic, low cardinality | 809 | 41.9 | 19.3× |
| Synthetic, high cardinality | 856 | 64.3 | 13.3× |
| Kestrel production (Ch. 1) | 820 | 66.7 | 12.3× |
⚠️ Four fields decide a clickstream benchmark, and a generator gets all four wrong: paths · referrers · user agents · identifiers. Small vocabularies dictionary-encode almost perfectly, so every columnar format looks better than it will on your data.
LIMIT nis not a sample — it is the first n, and the first n has a shape.Before quoting a compression ratio, say which data it was measured on.
Scan cost — where columnar actually wins
One column of twenty-eight, one day, 14M events:
| Format | Bytes read |
|---|---|
| Gzipped JSON / CSV / Avro | ~1 GB — must decompress and parse whole records |
| Parquet + zstd | ~9.4 MB |
~100× less, for the same answer — invisible in a compression comparison.
$1,176/year vs. $10 across 22 hourly dashboards on a per-byte meter. On a compute meter it appears as runtime instead: a minute of CPU against under a second. Same waste, different meter.
Codecs
| Codec | Ratio | Speed | Splittable whole-file |
|---|---|---|---|
| zstd | very good | fast | yes (in Parquet) |
| snappy / LZ4 | good | fastest | yes (in Parquet) |
| gzip | very good | slow | no |
| brotli | best | very slow | yes (in Parquet) |
zstd for stored data · snappy/LZ4 for intermediate (write once, read once) · gzip only for interchange. Do not tune the level without measuring.
Choosing — Kestrel
| Where | Format + codec |
|---|---|
| Kafka messages | Avro + registry, snappy |
| Bronze landing | JSON payload in a Parquet envelope, zstd |
| Silver / gold | Parquet, zstd (+ a table format) |
| Spark shuffle | internal, LZ4 |
| Partner exports | CSV or Parquet, per a written contract |
The three mistakes
- CSV as an internal storage format
- Row formats for analytical data — the 100×
- Optimizing format before layout — format is worth a factor of a few, layout a factor of tens. Fix layout first.
The ten-second diagnostic
f = pq.ParquetFile(path); rg = f.metadata.row_group(0)
for i in range(rg.num_columns):
c = rg.column(i)
print(f"{c.path_in_schema:20} {c.total_compressed_size/1e6:7.2f} MB "
f"{c.total_uncompressed_size/c.total_compressed_size:6.1f}x")
Compare per-column sizes, not totals. A column with a ratio near 1× is incompressible — random ids, hashes, IPs — and dominates the file. Check whether anything downstream needs it.