Storage Layers: Intermediate
What you will be able to do
Columnar Versus Row Storage
Explain why columnar storage accelerates analytical queries through I/O reduction, compression, and vectorized execution.
How the Bytes Are Arranged
| Layout | Disk Order | What a Scan of One Column Reads |
|---|---|---|
| Row store | (r1.c1, r1.c2, r1.c3) (r2.c1, r2.c2, r2.c3) ... | Every column of every row, even when only one is needed |
| Column store | (r1.c1, r2.c1, r3.c1, ...) (r1.c2, r2.c2, ...) ... | Only the bytes for the requested column |
Parquet and ORC Up Close
Why Columnar Compresses Better
When Row Storage Still Wins
- Queries read most or all columns of a small number of rows
- Workload is dominated by single-row INSERT, UPDATE, DELETE
- Application traffic demands sub-millisecond row fetches
- Transactions span multiple columns and need to commit atomically
- Queries scan a few columns across many rows
- Workload is bulk-load with infrequent updates
- Compression matters because storage cost is significant
- Vectorized execution can process column chunks at high speed
Vectorized Execution
Partitioning and File Pruning
Choose a partition key by query filter pattern and avoid the small-files trap that kills naive partitioning.
Hive-Style Partition Layout
Choosing a Partition Key
| Partition Key Property | Why It Matters | Common Choice |
|---|---|---|
| Used in most query filters | Pruning only helps queries that filter on the partition column | event_date or ingestion_date in nearly every fact table |
| Has moderate cardinality | Too few partitions yields no pruning; too many partitions creates millions of small files | Date (365 partitions/year) is the sweet spot for daily fact tables |
| Stable over time | If a value can change for a row, partition reassignment becomes a rewrite | Immutable timestamps; never partition by status that flips |
| Skewed but not catastrophically | A partition that holds 90% of the data defeats the purpose | Country sometimes works; user_id never works |
The Partition Cardinality Tradeoff
- ▸Too many partitions: millions of tiny files, listing dominates query time, cost explodes
- ▸Too few partitions: filters cannot prune effectively, scans read more than necessary
- ▸Wrong partition key: filters in actual queries do not match what the table is partitioned by
- ▸Skewed partitions: one partition is 100x larger than the rest, parallelism collapses
Multi-Level Partitioning
Clustering Versus Partitioning
- Splits data into separate file paths
- Pruning happens before files are opened
- Best for low-to-moderate cardinality columns
- Wrong key creates the small-files problem
- Reorders rows within files or partitions
- Pruning happens at the row group or page level
- Best for high-cardinality columns frequently filtered
- Adds maintenance: clustering can drift and need re-sorting
Pick the partition key by what queries actually filter on, not by what feels like a natural primary key. The right key is the one that appears in WHERE clauses across most of the workload.
- Partition fact tables by event_date or ingestion_date by default
- Aim for partitions that hold tens to hundreds of megabytes of data each
- Add clustering on high-cardinality filter columns instead of partitioning on them
- Partition by a high-cardinality key like user_id or order_id
- Add a second partition column unless its cardinality stays low
- Partition by a column whose value can change after a row is written
Compression: Bytes Versus CPU
Choose a compression codec by balancing ratio, decompression speed, and splittability for the workload's I/O versus CPU profile.
The Codecs in Use
| Codec | Compression Ratio | Decompression Speed | Typical Use |
|---|---|---|---|
| Snappy | Moderate (about 2-3x) | Very fast | Default for Parquet in Spark; balanced choice |
| GZIP | Higher (about 3-4x) | Slower than Snappy | Archival data where read frequency is low |
| ZSTD | Higher (about 3-5x) | Fast at low levels, slower at high | Modern default replacing both Snappy and GZIP in many stacks |
| LZ4 | Lower (about 2x) | Fastest decompression | Hot data where decompression latency matters most |
| Brotli | Highest (about 4-5x) | Slow | Cold archival; rarely used for analytical files |
Why Columnar Files Compress So Well
Splittability
- ▸Parquet with any of Snappy, ZSTD, GZIP, LZ4: splittable at row group boundaries
- ▸ORC with any codec: splittable at stripe boundaries
- ▸GZIP-compressed CSV or JSON: not splittable; one executor per file
- ▸BZIP2-compressed CSV: technically splittable but rarely supported by tooling
Compression Inside the Row Group
When Compression Hurts
- Data lives in object storage; reads cross the network
- Cold data read infrequently relative to write rate
- Storage cost is a meaningful line item
- Workloads are I/O-bound, not CPU-bound
- Data is hot in RAM or on local NVMe
- Workloads are CPU-bound, not I/O-bound
- Decompression latency is in the critical path
- Tooling has weak compression support and read errors are common
Predicate Pushdown
Apply predicate pushdown by writing filters that allow partition, row group, and bloom filter pruning, and verify pushdown via EXPLAIN.
The Three Levels of Pushdown
| Level | Where It Happens | What It Skips |
|---|---|---|
| Partition pruning | Before any file is opened | Whole directories that cannot match the filter |
| Row group pruning (file statistics) | After the file footer is read | Whole row groups whose min/max disagrees with the filter |
| Page pruning (column statistics) | After the row group's column metadata is read | Individual pages within a column whose min/max disagrees |
| Bloom filter pruning | After the bloom filter is consulted | Row groups whose bloom filter says the value is definitely absent |
Min/Max Statistics in Parquet
Bloom Filters
Pushdown in SQL Engines
When Pushdown Fails
- ▸Functions applied to partition columns: DATE(event_ts), CAST(amount AS STRING)
- ▸User-defined functions in WHERE clauses
- ▸OR clauses spanning multiple unrelated columns; only some engines push these
- ▸Subqueries that the planner cannot prove are deterministic
- ▸Filters using LIKE on a column not configured with a substring index
Verifying Pushdown
10TB Versus 100GB: A Worked Example
Walk through how columnar layout, partitioning, compression, and pushdown compose to turn a 10TB scan into a 100GB scan.
The Workload
Setup 1: CSV, No Partitioning
| Property | Value |
|---|---|
| Format | GZIP-compressed CSV files in S3 |
| Partitioning | None |
| Total size on disk | Roughly 10 TB |
| Bytes the query scans | All 10 TB |
| Wall clock time | Hours, depending on cluster size |
| Cost (Snowflake medium warehouse) | Tens of dollars per run |
Setup 2: Parquet, No Partitioning
| Property | Value |
|---|---|
| Format | Parquet with Snappy compression |
| Partitioning | None |
| Total size on disk | Roughly 1.5 TB (CSV compressed about 6-7x by columnar layout) |
| Bytes the query scans | Roughly 200 GB (only the columns event_date, user_id, country read) |
| Wall clock time | Minutes |
| Cost (Snowflake medium warehouse) | Single dollars per run |
Setup 3: Parquet, Partitioned by event_date
| Property | Value |
|---|---|
| Format | Parquet with ZSTD compression |
| Partitioning | event_date (daily) |
| Total size on disk | Roughly 1.2 TB (slightly better than Snappy with ZSTD) |
| Bytes the query scans | Roughly 100 GB (7 day partitions out of 540 total) |
| Wall clock time | Tens of seconds |
| Cost (Snowflake medium warehouse) | Cents per run |
How the Savings Stack
| Optimization | Bytes Scanned (10TB Baseline) | Reduction |
|---|---|---|
| CSV, no partitioning | 10 TB | 1x baseline |
| Parquet, no partitioning | 200 GB | 50x |
| Parquet, partitioned by event_date | 100 GB | 100x |
| Parquet, partitioned by event_date, sorted by country | 20 GB | 500x |
| Add bloom filter on user_id | Same scan, faster aggregation | 100x with finer pruning on user_id queries |
Each storage shape fits a job: the lake holds cheap raw files, the warehouse serves analytics, the operational DB serves the live app. Pick by how the data is read.
> An ad-tech company has a daily revenue report that takes fifty-eight minutes to run against eighteen months of clickstream data stored as CSV in S3. The dashboard's freshness SLA is twenty minutes. The team is being asked to add another year of history and a second region. The data engineer is asked: 'What is the smallest set of changes that would make this query fast enough to meet SLA, and what does each change actually buy?'
Columns, partitions, compression, and pushdown turn a 10TB scan into a 100GB scan
- Category
- Pipeline Architecture
- Difficulty
- intermediate
- Duration
- 32 minutes
- Challenges
- 0 hands-on challenges
Topics covered: Columnar Versus Row Storage, Partitioning and File Pruning, Compression: Bytes Versus CPU, Predicate Pushdown, 10TB Versus 100GB: A Worked Example
Lesson Sections
- Columnar Versus Row Storage (concepts: paColumnarVsRow)
The single most important fact about a storage format is whether it lays out rows or columns contiguously on disk. The choice flips which queries are fast. A row store wins when queries fetch entire rows by key. A column store wins when queries scan a few columns across many rows. Modern analytical workloads are dominated by the second pattern, which is why every cloud warehouse and every serious lake format uses columnar storage. How the Bytes Are Arranged Consider a table with twenty columns a
- Partitioning and File Pruning (concepts: paPartitioning)
Columnar layout helps a query read fewer columns. Partitioning helps a query read fewer files. The combination is what turns an analytical scan from minutes into seconds. Partitioning splits a table into separate file paths organized by the value of one or more partition columns. A query with a filter on a partition column reads only the matching paths. Done well, partitioning is the largest single performance improvement available to a data engineer. Done badly, it produces millions of small fi
- Compression: Bytes Versus CPU (concepts: paCompression)
Compression is the lever that trades CPU cycles for bytes. Smaller files mean fewer bytes read from disk or network, which usually wins. Smaller files also mean more CPU spent decompressing on read and compressing on write. The tradeoff is rarely close in modern analytical workloads: I/O is slow and getting slower relative to CPU, so the bytes saved are almost always worth the cycles spent. The interesting choice is which codec, not whether to compress. The Codecs in Use ZSTD has become the mode
- Predicate Pushdown (concepts: paPredicatePushdown)
Predicate pushdown is the technique of moving filter conditions as close to the storage layer as possible so the engine reads only the bytes that could match. Partition pruning is the coarsest form of pushdown. File-level statistics, row-group min/max, and bloom filters are finer forms. A well-designed Parquet file plus a smart query engine produces queries that scan a tiny fraction of the table while returning the same answer. The wins compound with partitioning and columnar layout. The Three L
- 10TB Versus 100GB: A Worked Example (concepts: paColumnarVsRow)
The four levers (columnar layout, partitioning, compression, predicate pushdown) compose. A worked example shows how the savings multiply. The setup is a real-shaped clickstream table at moderate scale: eighteen months of mobile events, twenty-eight columns wide, around two trillion total rows. The query is unremarkable: a daily count of unique users for one country in the last seven days. The same SQL runs three different ways across three table layouts. The bytes scanned change by two and a ha