Storage Formats, Partitioning, and Serialization Questions
Physical storage layout for analytics data. Covers columnar and row format internals and their trade-offs (Parquet row groups and column chunks, ORC stripes, Avro), serialization choices, compression codec and column-encoding selection, partitioning and clustering or bucketing strategy including partition-key choice and pruning, the small-file problem and compaction, file-size and row-group tuning, and open table formats (Iceberg, Delta Lake, Hudi) at the layout and metadata level: ACID commits, snapshots and time travel, manifest and metadata-tree structure, schema and partition evolution, and the catalog that tracks them. The scope is how data is laid out on disk and what that layout makes cheap or expensive. Tuning the queries that read it, and choosing which managed platform to run it on, are covered separately.
Compare compression codecs snappy, gzip, zstd, and lz4 for Parquet files in cloud analytics workloads. Discuss compression ratio, CPU cost on write and read, impact on query latency, and storage cost. Explain practical scenarios where each codec is a reasonable default.
Sample Answer
Direct answer. Snappy and LZ4 (Lempel-Ziv 4) are speed-optimized codecs: low compression ratio, very cheap to decompress, and the right default when a workload is CPU-bound or latency-sensitive on the read path. Gzip gives a meaningfully better compression ratio at real CPU cost on both write and read. Zstandard (Zstd) is the modern default for most analytics workloads: it beats Gzip on compression ratio while staying close to Snappy on decompression speed, which is why formats like Apache Iceberg moved their own default codec from Gzip to Zstandard.
Structured elaboration and worked example (executed, not just described).
import duckdb, os
con = duckdb.connect()
con.execute("""
CREATE TABLE telemetry AS
SELECT i AS id,
(['US','GB','DE','IN','BR'])[1 + (i % 5)] AS country,
1700000000 + i AS ts,
(i % 97) * 1.2345 AS val
FROM range(500000) t(i)
""")
for codec in ['uncompressed', 'snappy', 'lz4_raw', 'gzip', 'zstd']:
path = f'telemetry_{codec}.parquet'
con.execute(f"COPY telemetry TO '{path}' (FORMAT PARQUET, COMPRESSION '{codec}')")
print(codec, os.path.getsize(path), 'bytes')
Writing a 500,000-row synthetic table (a repeated low-cardinality string column, an integer timestamp, and a float column) as Parquet with each codec, changing nothing else, measured these real file sizes on DuckDB 1.5.5:
uncompressed 16,192,627 bytes (baseline)
snappy 4,465,314 bytes ratio 0.276
lz4_raw 4,040,321 bytes ratio 0.250
gzip 1,582,928 bytes ratio 0.098
zstd 1,058,944 bytes ratio 0.065
This matches the general pattern industry benchmarks report: Snappy and LZ4 land in the same ballpark (roughly 25-28% of uncompressed size here, since this run's low-cardinality country column is unusually repetitive and compresses hard under every codec), because both are designed around the same speed-first trade-off; Gzip pulls meaningfully ahead on ratio (10%); Zstandard beats even Gzip's ratio (7%) while its decompression speed sits much closer to Snappy's than to Gzip's, which is the specific property that has pushed it into being the modern default. The exact ratios shift with data shape, but the ORDERING (zstd best, gzip second, snappy and lz4 close behind and worst) is the stable, load-bearing finding and holds across independently published benchmarks.
- Compression ratio. Directly measured above; Zstandard > Gzip > (Snappy ≈ LZ4) for this workload shape, consistently across two independent runs of this experiment. The exact numbers shift with data (highly repetitive data compresses further under every codec; high-entropy data compresses less under all of them), but the relative ordering is stable across most real workloads.
- CPU cost on write and read. Snappy and LZ4 are optimized to be cheap in both directions. Gzip costs meaningfully more CPU on both write and especially read (decompression). Zstandard's write cost is configurable via its compression-level parameter (higher levels trade write CPU for a better ratio) and its decompression cost, the side that matters most since data is typically written once and read many times, stays close to Snappy/LZ4 regardless of the level chosen for writing.
- Impact on query latency. For an I/O-bound query (network- or disk-bound, common when reading from cloud object storage), a smaller compressed size directly reduces bytes transferred, which usually dominates over the extra decompression CPU, favoring Zstandard or Gzip. For a CPU-bound query (already reading from fast local/cached storage, CPU is the bottleneck), the cheaper decompression of Snappy or LZ4 can win even though more bytes are read.
- Storage cost. Directly proportional to the measured ratios above; at petabyte scale the difference between a 0.66x codec and a 0.38x codec is a very large, very real storage bill difference.
Trade-offs & pitfalls. A common mistake is picking a codec once at table-creation time and never revisiting it: workload shape (network-bound vs CPU-bound, write-once-read-many vs frequently-rewritten) changes over a table's life, and the "reasonable default" answer genuinely differs across teams. As a practical default: Zstandard at a moderate compression level is the safest starting point for most analytics workloads today (better ratio than Gzip, decompression speed close to Snappy), reserving Snappy or LZ4 specifically for workloads where write-side CPU is the tightest constraint (very high-frequency streaming writes where every millisecond of write-path CPU matters more than the resulting file size).
Design a storage layout for petabyte-scale time-series feature tables that must support efficient time-range queries and point-in-time joins for training backfills. Discuss partitioning strategies, file formats, compaction, bloom filters/indexes, and metadata services to locate partitions quickly.
Sample Answer
Direct answer. A petabyte-scale time-series feature table needs a storage layout built around two access patterns at once: efficient time-range scans (recent-window queries for monitoring and drift checks) and point-in-time joins (retrieving the feature values that were TRUE as of a specific historical timestamp, for training-set construction without leaking future information). The layout that supports both is a columnar format (Parquet or a table format built on it), partitioned by time, with an entity-and-timestamp-aware indexing strategy layered on top so a point-in-time join doesn't have to scan every partition.
Structured elaboration.
- Partitioning strategy. Partition by a time column at a granularity that matches query patterns (daily is a common default for feature tables refreshed daily; finer granularity like hourly helps if features are updated intraday but multiplies file and partition count, so it trades query narrowness against small-file and metadata risk). Time partitioning directly serves both target access patterns: a time-range scan prunes to the relevant date partitions, and a point-in-time join for a training example dated
Tonly needs to consider partitions up to and includingT. - File formats. Parquet (or a table format like Apache Iceberg or Delta Lake layered on Parquet) for the columnar benefits already covered: selective column reads (a training job for one model rarely needs every feature column), compression, and predicate pushdown on the timestamp and entity-id columns.
- Compaction. Feature pipelines are often incremental (a new partition lands daily or hourly), which risks the same small-files problem seen in any incremental columnar write. A scheduled or triggered compaction step that merges small incremental files into properly-sized ones keeps both the time-range scan and the point-in-time join fast, since both have to open fewer files per partition touched.
- Bloom filters / indexes. A point-in-time join is fundamentally an equality (or range) lookup on
entity_idcombined with atimestamp <=filter; a Bloom filter on the entity-id column lets the engine skip whole row groups or files that provably don't contain a given entity's rows, which matters enormously when a training set only touches a small fraction of entities in any one query. - Metadata services. At petabyte scale with potentially years of daily or hourly partitions, listing and locating the right partitions and files becomes its own bottleneck; a metadata catalog (a table format's native metadata layer, or an external service backed by a catalog like the Hive Metastore or AWS Glue) that can answer "which files might contain entity X at or before time T" without a brute-force object-store listing operation is what keeps point-in-time joins tractable at scale.
Worked example. Consider a point-in-time join for a training set of 10 million (entity, label_timestamp) pairs against a feature table partitioned daily over 3 years (about 1,095 partitions). Without partition pruning, every one of the 10 million lookups risks scanning across all 1,095 partitions; with time partitioning and a catalog that can answer "which partitions have date <= label_timestamp" directly from metadata, the join only opens the partitions actually needed per label timestamp, typically a small handful clustered around each label's date, not the full history. Layering an entity-id Bloom filter on top further narrows which FILES within those partitions need to be opened at all.
Trade-offs & pitfalls. A common mistake is partitioning purely by ingestion time and joining on event or feature-effective time; if those two clocks diverge (late-arriving feature updates, backfills), a point-in-time join can silently miss or double-count rows unless the join logic and the partition-pruning logic agree on which timestamp is authoritative.
Compare Parquet, ORC, Avro, and JSON as storage formats for a modern data lake used by analytics teams. For each format mention whether it is columnar or row-based, typical compression and encoding support, metadata features such as rowgroups or stripes, schema evolution support, and common use cases where you would prefer it.
Sample Answer
Direct answer. Parquet and ORC (Optimized Row Columnar) are both columnar, self-describing binary formats built for analytics; Avro is row-based and built for compact serialization and schema evolution, especially in streaming pipelines; JSON is row-based, text-based, and human-readable but carries no compact type or compression story of its own. For a modern data lake feeding analytics teams, Parquet is the default choice in most ecosystems today, ORC is the equivalent default inside the Hive/Hadoop-heritage ecosystem, Avro is the right choice for the write path (event streams, Kafka topics, change-data-capture) rather than the analytical read path, and JSON should be treated as an interchange or logging format, not a long-term analytical storage format.
Structured elaboration.
| Format | Layout | Compression / encoding | Metadata | Schema evolution | Typical use |
|---|---|---|---|---|---|
| Parquet | Columnar (row groups -> column chunks -> pages) | Per-column: dictionary, run-length, delta, plus a general codec (Snappy, Gzip, Zstandard) | Row group and page-level statistics (min/max, null count) in the footer | Column-ID-based in modern writers (e.g. Apache Iceberg's use of Parquet); safe add/drop of columns, riskier reorders under naive position-based readers | Default analytical lake/warehouse format across Spark, Trino/Presto, Snowflake, BigQuery, Athena |
| ORC | Columnar (stripes -> column streams) | Similar per-column encodings, plus built-in lightweight indexes and optional Bloom filters | Stripe-level statistics and an embedded index | Supported, historically the more mature format inside Hive | Hive-ecosystem-heavy lakes, still common at large existing Hadoop deployments |
| Avro | Row-based | General-purpose compression (Deflate, Snappy) over whole records, no per-column encoding | Schema stored with the data (or in a schema registry) | Strong, well-defined forward/backward compatibility rules (add optional field, don't change types) | Kafka payloads, CDC streams, RPC serialization, the write side of a pipeline before it lands in a columnar table |
| JSON | Row-based, text | None built in; relies on external compression (gzip) at the file level | None; every record repeats every field's name as text | Trivial in the loose sense (add any field) but no compatibility guarantees at all | Logs, API payloads, one-off exports; a poor long-term analytical format because of repeated field names and lack of type information |
Worked example. The compression numbers from the columnar-vs-row comparison above generalize here: a real 500,000-row Parquet file with a repeated low-cardinality column compressed to roughly a third of its uncompressed size using Zstandard, purely from column-local redundancy. Avro compresses a comparable dataset less aggressively per byte because compression operates over whole interleaved records rather than per-column, though it still beats raw JSON, which pays a large fixed cost per record for repeating every field name as text (a JSON record like {"user_id": 42, "event_type": "click"} spends roughly half its bytes on the literal strings "user_id" and "event_type", repeated in every single record).
Concretely: a raw clickstream landing zone from Kafka is commonly written as Avro (compact, schema-registry-governed, fast to append), then an ETL/ELT job converts it to Parquet for the analytical layer that BI tools and ad-hoc SQL actually query, precisely because the two stages have different requirements (fast, schema-safe writes versus fast, selective, compressed reads).
Trade-offs & pitfalls. Nested types (arrays, structs, maps) are supported by Parquet and ORC but push against the columnar model: a deeply nested or highly variable schema can defeat much of the benefit of columnar pruning, because the engine may still need to reconstruct whole nested structures. JSON's nested-type flexibility is exactly why it survives as a raw landing format even though it is a poor analytical format. A common mistake is picking Avro because "it's a good general format" for a table BI tools query directly: Avro's row-based layout means a SELECT SUM(x) FROM t still has to deserialize every field of every row, which loses most of what makes an analytical engine fast.
Compare row-oriented storage (e.g., Postgres) vs columnar storage (BigQuery, Snowflake) for ML workloads. Discuss how storage format affects compression, IO patterns for feature retrieval (many features vs few), update cost, and best practices for batch training vs low-latency online serving.
Sample Answer
Direct answer. A row-oriented transactional store like Postgres is optimized for retrieving one full record by key (all of a user's features in one read) and for cheap, frequent updates to individual rows; a columnar analytical store like BigQuery or Snowflake is optimized for scanning and aggregating a subset of columns across a huge number of rows. For ML workloads specifically, this maps directly onto two different serving needs: low-latency online feature retrieval for a single entity at inference time favors a row store, while offline batch feature computation and large-scale training-set construction favor a columnar store.
Structured elaboration.
- Compression. A columnar store compresses each feature column independently, exploiting the fact that a single feature's values are far more self-similar than a whole row's mixed-type values are. A runnable illustration, holding row count fixed at 500,000 and comparing a low-cardinality column (5 distinct country codes) against a high-cardinality column (500,000 distinct UUIDs) written to Parquet separately:
import duckdb, os
con = duckdb.connect()
con.execute("CREATE TABLE low_card AS SELECT i AS id, ['US','GB','DE','FR','IN'][1 + (i % 5)] AS country FROM range(500000) t(i)")
con.execute("CREATE TABLE high_card AS SELECT i AS id, md5(i::VARCHAR) AS customer_uuid FROM range(500000) t(i)")
con.execute("COPY low_card TO 'low_card.parquet' (FORMAT PARQUET, COMPRESSION 'snappy')")
con.execute("COPY high_card TO 'high_card.parquet' (FORMAT PARQUET, COMPRESSION 'snappy')")
print("low-cardinality (5 distinct values) column file:", os.path.getsize('low_card.parquet'), "bytes")
print("high-cardinality (500000 distinct values) column file:", os.path.getsize('high_card.parquet'), "bytes")
Output:
low-cardinality (5 distinct values) column file: 2012514 bytes
high-cardinality (500000 distinct values) column file: 18106177 bytes
The high-cardinality column's file is roughly 9x larger (18,106,177 / 2,012,514 ≈ 9.0), i.e. the low-cardinality column compresses to roughly a ninth the size of the high-cardinality one, for the identical row count, purely from dictionary/encoding efficiency. A row store gets far less benefit from this because it stores a whole heterogeneous row contiguously.
- I/O patterns for feature retrieval. Retrieving MANY features for ONE entity (a typical online-serving lookup: "give me all 40 features for user 12345 right now") is a row-store-favorable access pattern: one index lookup returns the whole row. Retrieving FEW features for MANY entities (a typical offline training-set build: "give me these 6 features for the 10 million users active last month") is a columnar-favorable access pattern: only the needed columns are scanned, across all rows.
- Update cost. Row stores support efficient single-row updates (a feature value changes, one row is rewritten in place, transactionally). Columnar analytical formats are generally append-oriented or batch-rewrite-oriented; updating one row's one feature in a Parquet-backed table typically means rewriting the whole file or relying on a table format's merge-on-read mechanism, both far more expensive per-update than a row store's in-place write.
- Batch training vs low-latency online serving. Training reads large, wide slices of historical feature data in bulk, latency per read barely matters because the whole read happens once per training run; a columnar warehouse is the natural fit and is where most feature engineering and label-joining happens. Online serving needs a single feature vector back in single-digit milliseconds for one entity at request time; a row store (or a purpose-built low-latency key-value store) is the natural fit, and few production systems try to serve real-time inference traffic directly off a columnar analytical warehouse.
Worked example. This is exactly the shape a feature store's "offline store / online store" split addresses: the offline store (commonly a columnar warehouse) computes and stores wide historical feature tables efficiently and supports point-in-time-correct joins for training-set construction; a separate online store (commonly a row-oriented or key-value store, materialized FROM the offline store on a schedule) serves the low-latency single-entity lookups inference needs. Trying to serve online inference traffic directly from the columnar warehouse means paying a columnar engine's per-query planning and I/O overhead for a workload it was never designed for, single-row point lookups, which typically produces latency far outside what an online prediction path can tolerate.
Trade-offs & pitfalls. A common mistake is under-provisioning the offline-to-online materialization pipeline: if the online store is refreshed too infrequently, inference sees stale features, which is a training-serving skew risk distinct from the storage-format choice itself but caused directly by treating "which store" as the only decision and not also designing the sync path between them.
Architect a lakehouse that supports ACID updates, compaction, versioned time-travel, and efficient incremental reads for model training using technologies such as Delta Lake, Iceberg, or Hudi. Explain transaction metadata scaling, compaction strategies, and how your design supports both analytical scans and point-updates.
Sample Answer
Direct answer. A lakehouse built for model training on top of Delta Lake, Iceberg, or Hudi needs to satisfy a requirement general analytical lakehouses don't: efficient INCREMENTAL reads, being able to ask "give me every row added or changed since the last time I read this table" without rescanning the whole dataset, since a training pipeline that reruns feature computation on the full history for every incremental training job wastes enormous compute. ACID (atomicity, consistency, isolation, durability) transactions, compaction, and versioned time travel are the same three properties any lakehouse needs, but here they specifically enable that incremental-read requirement: each write producing a new, addressable version (a snapshot in Iceberg, a log version in Delta Lake) is what lets a training job precisely identify "everything new since version N" rather than approximating it.
Structured elaboration.
- Transaction metadata scaling. As with Iceberg's manifest-tree design specifically, the metadata layer has to stay cheap to query even as the table accumulates years of daily or hourly versions; for an ML training use case specifically, this means the incremental-read query (diffing version N against the current version) should cost roughly proportional to the SIZE OF THE CHANGE, not the size of the whole table's history, which is exactly what a well-compacted manifest or log-checkpoint structure provides.
- Compaction strategies. Training reads benefit from well-compacted, properly-sized files for the same I/O-efficiency reasons any analytical scan does; but compaction for a training-serving table also has to preserve the ability to correctly reconstruct "what changed between version N and version N+1," so compaction operations themselves need to be versioned/snapshotted rather than silently rewriting history in a way that breaks incremental-read bookkeeping.
- Supporting both analytical scans and point-updates. A full historical training run (a periodic full retrain) needs efficient large-scale analytical scanning, favoring well-compacted, columnar-optimized Copy-on-Write-style storage. Meanwhile, upstream feature pipelines correcting mislabeled or late-arriving data need point-updates (touching individual rows or small batches) without forcing a full-table rewrite, favoring Merge-on-Read-style write absorption. Supporting both well within one table typically means either accepting Merge-on-Read's read-time merge cost during scans (acceptable if compaction keeps pending deltas small) or architecturally separating "mostly-immutable historical training data" from "actively-corrected recent data" into different partitions or tiers with different write modes.
Worked example. A recommendation-model training pipeline that retrains incrementally every few hours on new interaction events: each incremental training job records the table's current snapshot/version ID after it finishes, and the NEXT run reads only the rows added since that recorded version (an Iceberg incremental scan between two snapshot IDs, or a Delta Lake CDF (change data feed) read between two log versions), rather than rescanning the full multi-year interaction history every few hours. This is only possible because the table format's ACID, versioned-snapshot design gives every write a precise, addressable boundary to diff against; a raw Parquet directory with no such versioning has no reliable way to answer "what's new since 3 hours ago" other than relying on file modification timestamps, which are fragile (a compaction job touching old files without changing their content would falsely appear as "new" under a naive timestamp-based approach).
Trade-offs & pitfalls. A common mistake is designing the incremental-read path around file-system timestamps or a manually-maintained "last processed" watermark instead of the table format's native version/snapshot identifiers; the native versioning is specifically designed to survive compaction, backfills, and out-of-order writes correctly, while a timestamp-based approach is vulnerable to exactly those operations silently corrupting the incremental boundary.
Unlock Full Question Bank
Get access to all 11 Storage Formats, Partitioning, and Serialization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.