Warmup Guide — Storage & Columnar Formats
Zero-to-principal primer for Phase 08: S3/HDFS as the lake substrate, Parquet and ORC internals (row groups, pages, stats, encodings), Avro vs columnar, and the data-layout decisions that determine every downstream query's speed and cost.
Table of Contents
- Chapter 1: S3 — Object Storage as a Lake
- Chapter 2: HDFS and Why S3 Won
- Chapter 3: Row vs Columnar
- Chapter 4: Parquet Internals
- Chapter 5: Encodings and Compression
- Chapter 6: Predicate Pushdown and Why Sorting Matters
- Chapter 7: ORC, Avro, JSON, CSV — Choosing a Format
- Chapter 8: File Sizing and the Small-File Problem
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: S3 — Object Storage as a Lake
S3 is the substrate of the modern lake, and it is object storage, not a filesystem — a distinction that explains many gotchas:
- Objects, not files: a flat key→bytes store. "Directories" are a fiction (key prefixes);
there's no cheap
rename(a rename is a copy + delete — which is why naive Spark/Hadoop commit protocols are slow/unsafe on S3 and why table formats, P09, exist). - Consistency: since Dec 2020, S3 has strong read-after-write consistency for all operations — the old "eventual consistency" footguns (S3Guard, etc.) are gone. Know this is current; interviewers test whether you're up to date.
- Request scaling: throughput scales per prefix — roughly 3,500 PUT/s and 5,500 GET/s per prefix, and S3 auto-scales as you spread keys across prefixes. A million tiny files under one prefix → throttling; spreading prefixes (and fewer, larger files) → speed.
- Costs: storage ($/GB-month by tier), requests (PUT/GET per 1,000 — why small files cost money even idle), and data transfer (cross-region/egress). Lifecycle policies tier cold data to IA/Glacier automatically.
- Security (P14): SSE-S3 / SSE-KMS encryption at rest, bucket policies, VPC endpoints, cross-region replication for DR.
Chapter 2: HDFS and Why S3 Won
HDFS was the original lake storage: a NameNode holds the filesystem metadata (the directory tree, block→DataNode map) and DataNodes store fixed-size blocks (default 128 MB), replicated (default 3×) for durability. It's fast for colocated compute (data locality) but has two fatal-at-cloud-scale problems: the NameNode is a memory-bound single point (millions of small files = NameNode heap death — the small-file problem's origin) and storage is coupled to compute (scaling one means scaling the other).
S3 won the lake because it decouples storage from compute (scale independently; spin clusters up/down against durable storage), is effectively infinite and 11-nines durable, and is cheaper. The cost: no data locality (compute reads over the network) and no atomic rename (→ table formats). Know HDFS to understand what EMR/Spark inherited and what you're migrating from (P07/P15).
Chapter 3: Row vs Columnar
The fundamental storage choice:
- Row-oriented (CSV, JSON, Avro): all of row 1, then all of row 2… Great for writing whole records and reading whole rows (OLTP, ingestion). Bad for analytics: to sum one column you read every column of every row.
- Columnar (Parquet, ORC): all of column A, then all of column B… Great for analytics:
- Projection: reading 2 of 50 columns reads ~4% of the bytes.
- Compression: a column is homogeneous (all ints, all of one enum) → compresses far better than mixed rows.
- Skipping: per-chunk stats let you skip data that can't match (Ch. 6). Analytics is overwhelmingly "aggregate a few columns over many rows," so columnar wins — which is why the lakehouse is built on Parquet/ORC.
Chapter 4: Parquet Internals
Parquet's hierarchy (the lab builds it):
File
└── Row Group (≈128 MB of rows — the unit of parallelism & skipping)
└── Column Chunk (one column's data for this row group)
└── Page (≈1 MB — the unit of encoding/compression/IO)
+ Column statistics per chunk: min, max, null_count, (distinct_count)
File footer: schema + row-group metadata + column stats + offsets
- A reader reads the footer first (metadata), then reads only the column chunks it needs (projection) from only the row groups whose stats can match (pushdown, Ch. 6) — often a tiny fraction of the file.
- Row-group size trades parallelism vs skipping granularity vs memory; ~128 MB is typical.
- Repetition/definition levels encode nested/optional fields (Dremel encoding) — how Parquet stores arrays/maps/structs and nulls compactly.
Chapter 5: Encodings and Compression
Two layers, and the order matters:
- Encoding (logical, type-aware): dictionary encoding maps distinct values → small
integer codes (huge win for low-cardinality columns — the lab's
dictionary_encode), then RLE + bit-packing on the codes (huge win for runs/sorted data — the lab'srle_encode). Delta encoding for sorted integers/timestamps. - Compression (byte-level): snappy (fast, default), zstd (better ratio, now often preferred), gzip (smaller, slower). Applied to the already-encoded pages.
The lesson: columnar + dictionary + RLE is why a column compresses 10× where the same data as JSON rows barely compresses — and why sorting the data first (so runs form) multiplies the win. This is also a cost lever (fewer bytes = cheaper scans, P10).
Chapter 6: Predicate Pushdown and Why Sorting Matters
Predicate pushdown = the reader uses per-row-group statistics to skip groups that
can't contain a matching row. For WHERE id = 55, a group with min=60, max=69 is skipped
without reading its data. The lab's row_group_can_match is exactly this.
The crucial asymmetry: statistics prove ABSENCE, not presence. A group with min=50, max=59 might contain id=55, so it's scanned and row-filtered; a group with min=60 can't,
so it's skipped. This is why pushdown's effectiveness depends entirely on how tight the
per-group min/max ranges are — and that depends on data layout:
- If the data is sorted by
id, each row group covers a tight, disjointidrange, so a point/range query skips almost everything. Massive win. - If
idis randomly distributed, every group's[min, max]spans the whole range, so nothing can be skipped. Pushdown does nothing.
This is the entire motivation for sorting / clustering / Z-ordering (P09): you lay data out so the columns people filter on have tight per-group stats. "Why is my Parquet query still slow despite pushdown?" is almost always "your data isn't sorted on the filter column."
Chapter 7: ORC, Avro, JSON, CSV — Choosing a Format
| Format | Orientation | Best for | Avoid for |
|---|---|---|---|
| Parquet | columnar | analytics on the lake; Spark/Trino/Athena | tiny row-by-row writes |
| ORC | columnar | Hive-centric analytics; has bloom filters + strong indexes | non-Hadoop ecosystems sometimes |
| Avro | row | ingestion/streaming, schema evolution, write-heavy | column-pruned analytics |
| JSON | row, text | interchange, debugging, external APIs | high-volume analytics (cost) |
| CSV | row, text | human/legacy interchange | anything typed or large |
- Parquet vs ORC: both excellent columnar formats; ORC has historically stronger built-in indexes/bloom filters and is Hive-native; Parquet has the broader ecosystem. Pick by ecosystem; don't agonize.
- Avro's niche: row-oriented + schema-carrying makes it ideal for the ingestion side (Kafka, write-heavy, evolving schemas — P03) where you write whole records and rarely column-prune. The pattern: Avro on the wire/landing, Parquet on the lake.
Chapter 8: File Sizing and the Small-File Problem
The recurring villain, now at its source:
- Target file size: 128 MB – 1 GB. Too small → the small-file tax (metadata blowup, S3 request costs, listing slowness, poor scan throughput, HDFS NameNode pressure). Too large → poor read parallelism, memory pressure, slow single-file operations.
- Where small files come from: Spark writing one file per partition per task (P06), over-partitioning a Hive table (P07), streaming sinks committing frequently (P04), and per-record/per-minute micro-writes.
- Fixes:
repartition/coalesceto target before writing (P06); compaction jobs (P09 table formats automate this); right-sized partitioning; batching streaming writes. - The cost framing: small files inflate scan cost (Athena reads/opens more, $/TB up), request cost (more GETs), and operational cost (slow listing, metastore pressure) — so compaction is a cost program (a JD deliverable), not just tidiness.
Lab Walkthrough Guidance
Lab 01 — Parquet Reader & Predicate Pushdown, suggested order:
dictionary_encode/rle_encode/rle_decode— the encodings (and roundtrip)._column_stats+build_parquet— chunk into row groups, compute min/max/null.row_group_can_match— the pushdown logic; remember stats prove absence; all-null group skipped;!=not prunable.scan— skip via pushdown, then row-filter + project; report scanned/skipped.- Then the extension: sort the data on the predicate column, rebuild, and watch
row_groups_skippedjump — Ch. 6 made tangible.
Success Criteria
You are ready for Phase 09 when you can, from memory:
- Explain S3 as object storage: prefix scaling, request costs, no atomic rename, strong consistency.
- Explain why S3 (decoupled storage/compute) replaced HDFS.
- Contrast row vs columnar and why analytics wants columnar (projection, compression, skipping).
- Draw Parquet's file→row-group→column-chunk→page hierarchy and the role of stats.
- Explain dictionary + RLE encoding and why sorting amplifies compression and pushdown.
- Explain why pushdown proves absence and why data layout (sorting) determines its value.
- Pick a format for ingestion vs analytics vs interchange, and state the file-size target.
Interview Q&A
Q: Your Athena query has a WHERE on an indexed column but still scans the whole table.
Why?
Almost certainly the data isn't sorted/clustered on that column, so every Parquet row group's
[min,max] for it spans the full range and predicate pushdown can skip nothing — stats only
prove absence, and with overlapping ranges they can't. Confirm by inspecting row-group stats;
the fix is to rewrite the data sorted (or Z-ordered, P09) on the filter column so each group
covers a tight, disjoint range, plus partition on a coarse column (date) for directory-level
pruning. Also check it's not a small-file problem inflating per-file overhead. The point:
columnar skipping is a layout property, not an automatic one.
Q: When would you store data as Avro instead of Parquet? On the ingestion/write side. Avro is row-oriented and schema-carrying, which suits whole- record writes, streaming, and schema evolution (P03) — you rarely column-prune a Kafka topic. Parquet is columnar and shines on the read/analytics side where you project a few columns over many rows. The common architecture is Avro on the wire and at landing, then a job rewrites to Parquet (sorted, right-sized) for the analytical lake. Using Parquet for high-frequency row-by-row ingestion, or Avro for big analytical scans, is using each against its grain.
Q: A pipeline writes millions of 1 MB Parquet files. What's wrong and what does it cost? That's the small-file problem: each file carries footer/metadata overhead, S3 charges per GET/PUT (so even idle they cost money), listing is slow, and scans can't reach good throughput — and downstream Athena/Trino pay more per query ($/TB plus per-file overhead). It usually comes from too many Spark output partitions/tasks or over-partitioning. The fix is to target 128 MB–1 GB files (coalesce/repartition before write), partition sensibly (date, not user_id), and run compaction — ideally via a table format (P09) that automates it. It's a cost program, not housekeeping.
References
- Parquet format spec and the Dremel paper (Melnik et al., 2010) for the nested encoding
- Apache ORC spec — stripes, indexes, bloom filters
- Kleppmann, DDIA Ch. 3 (column-oriented storage) & Ch. 4 (Avro)
- Amazon S3 performance guidelines — prefix scaling, request rates
- S3 strong consistency announcement (2020)