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 n is 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

  1. CSV as an internal storage format
  2. Row formats for analytical data — the 100×
  3. 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.