Education › Data › Stage 1: Foundations

Storage formats & the lakehouse

Row vs columnar, Parquet, object storage layouts, table formats (Iceberg, Delta) and partitioning.

Beginner ~35 min read Module 4 of 16

Where and how data is stored decides what every later query costs. A row-oriented file must be read in full to sum one column; a columnar file reads only that column. A folder of ten thousand small files is slow to list before a single byte is read; a well-partitioned layout lets a query skip almost everything. And a plain folder of files cannot be updated safely by two writers or read consistently while it changes, which is the problem open table formats solve. This module explains row versus columnar storage, why Parquet is the standard file, how to lay data out in object storage, and how table formats such as Apache Iceberg and Delta Lake turn a bucket into a database-like table — the foundation of the lakehouse.

After this module you can
  • Explain row-oriented versus columnar storage and choose the right one for a workload
  • Read and configure Parquet: row groups, compression, statistics and predicate pushdown
  • Lay out data in object storage with partitioning and file sizing that queries can exploit
  • Describe what an open table format adds: ACID transactions, schema evolution, time travel, hidden partitioning
  • Choose between a warehouse, a lake and a lakehouse for a given team and workload

Rows versus columns

A row store keeps all the fields of one record together (CSV, JSON lines, a transactional database's heap). It is right for reading or writing whole records: fetch order 1042, insert a new order. A column store keeps all the values of one column together (Parquet, ORC, every analytical warehouse). It is right for scanning a few columns over many rows: total amount for last quarter touches one column and ignores the other forty. Columnar data also compresses far better, because a column holds similar values (many repeated countries, sorted timestamps), and encodings like dictionary and run-length exploit that.

WorkloadRow storeColumn store
Fetch one record by keyFast: one locationSlow: one value from each column file
Sum one column over a billion rowsReads everythingReads one column, compressed
Frequent small updatesNaturalRewrite files or use a table format
Compression ratioModestHigh: similar values together

This is why operational databases and analytical systems differ, and why pipelines typically move data from row-oriented sources into columnar files or warehouses. The conversion point is usually the first thing a pipeline does after landing raw data.

Parquet, properly

A Parquet file is split into row groups (typically 64 to 512 MB of rows), each containing one column chunk per column, each chunk in pages. The footer holds the schema and, per column chunk, statistics: min, max, null count, and optionally bloom filters. A reader that wants WHERE ordered_at >= '2026-03-01' inspects the statistics and skips every row group whose max is earlier — predicate pushdown — and reads only the columns the query names — projection pushdown. Sorting data within a file by the columns you filter on makes the statistics tight and the skipping effective.

Inspecting what a reader sees: schema, row groups and per-column statistics. If min and max span the whole range, sorting will help.
python
import pyarrow.parquet as pq

f = pq.ParquetFile("orders/month=2026-03/part-0.parquet")
print(f.schema_arrow)
meta = f.metadata
print("row groups:", meta.num_row_groups, "rows:", meta.num_rows)

col_idx = f.schema_arrow.get_field_index("ordered_at")
for rg in range(meta.num_row_groups):
    col = meta.row_group(rg).column(col_idx)
    st = col.statistics
    print(rg, col.path_in_schema, st.min, st.max, "nulls:", st.null_count,
          "compressed MB:", round(col.total_compressed_size / 1e6, 1))

Write with sensible settings: zstd or snappy compression, row groups of around 128 MB, dictionary encoding on (the default), and timestamps with an explicit unit and timezone. Avoid many tiny files: each file costs a listing and a footer read, and a query over ten thousand 1 MB files is slower than over eighty 128 MB files holding the same data. Compaction — periodically rewriting small files into large ones — is routine maintenance.

Note

Parquet is an immutable file. "Updating a row" means rewriting the file that contains it. That constraint is what table formats manage for you.

Layout in object storage

Object storage (S3, GCS, Azure Blob) is cheap, durable and effectively infinite, with two properties that shape layout: listing is slow and per-object requests have latency, so large objects and shallow prefixes win. Partitioning organises files into prefixes by a column with low cardinality that queries filter on, typically date: orders/dt=2026-03-01/part-0.parquet. A query for one week lists seven prefixes and reads only those files. Over-partitioning (by hour, by customer id) produces thousands of tiny files and slow listings; under-partitioning forces full scans. Aim for partitions of hundreds of MB to a few GB.

A lake layout with zones and partitioning. Raw is immutable; everything else can be rebuilt from it.
text
s3://acme-lake/
  raw/                              immutable, as received, partitioned by arrival
    orders_api/dt=2026-03-01/hour=13/batch-7f3c.jsonl.gz
  bronze/                           parsed to Parquet, typed, still one row per source record
    orders/dt=2026-03-01/part-0000.parquet
  silver/                           cleaned, deduplicated, conformed; table-format managed
    orders/                         (Iceberg table: metadata/ + data/)
  gold/                             business-level models and aggregates
    daily_revenue_by_region/

rules
  raw:    never modified; retention by policy; replay source for everything below
  files:  128 MB - 1 GB Parquet, zstd; compaction job weekly
  keys:   partition by date of the event, not date of load

The zone names vary (raw/bronze/silver/gold, landing/staging/curated); the idea is constant: an immutable raw layer you can always replay, progressively cleaner layers built by code, and business models at the top. Store the raw data compressed in its original form, because parsing bugs are discovered later and the raw bytes are the only thing that can fix them.

Table formats: the lakehouse

A folder of Parquet files has no transactions: a reader can see a half-written batch, two writers can corrupt each other, a schema change means rewriting everything, and deleting a customer's rows for a privacy request means finding and rewriting the right files by hand. Open table formats — Apache Iceberg, Delta Lake, Apache Hudi — add a metadata layer over the files: a log of snapshots, each listing exactly which files make up the table at that version. Writers commit new snapshots atomically; readers see one consistent snapshot; old snapshots enable time travel and rollback; and the metadata carries the schema, so columns can be added, renamed or reordered without rewriting data.

CapabilityPlain Parquet folderIceberg / Delta table
Concurrent writersCorruption riskOptimistic concurrency, atomic commits
Consistent reads during writesNoSnapshot isolation
Update / delete rowsRewrite files by handMERGE, DELETE with row-level tracking
Schema changeRewrite everythingAdd, rename, drop, reorder columns in metadata
Partition changeRewrite everythingIceberg: partition spec evolution, hidden partitioning
Time travelNoQuery any past snapshot; roll back
Iceberg from the engine's point of view (Spark SQL shown): create with hidden partitioning, merge, then read the table as it was yesterday.
sql
CREATE TABLE lake.silver.orders (
  order_id    BIGINT,
  customer_id BIGINT,
  ordered_at  TIMESTAMP,
  status      STRING,
  amount      DECIMAL(12,2)
) USING iceberg
PARTITIONED BY (days(ordered_at));        -- hidden partitioning: queries filter on ordered_at, not a dt column

MERGE INTO lake.silver.orders t
USING lake.bronze.orders_batch s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;

-- what did the table look like before today's merge?
SELECT COUNT(*) FROM lake.silver.orders TIMESTAMP AS OF '2026-03-01 00:00:00';

-- maintenance: compact small files and expire old snapshots
CALL lake.system.rewrite_data_files('silver.orders');
CALL lake.system.expire_snapshots('silver.orders', TIMESTAMP '2026-02-01 00:00:00');
OBJECT STORAGE: ONE COPY OF THE DATAland, never editparseclean, mergecommit snapshotcurrent pointerresolve tableread same filesSourcesAPIs, DBs, eventsraw/immutable, as receivedbronze/typed Parquetsilver/ gold/Iceberg tablesTable metadatasnapshots, schemaCatalogname -> snapshotEnginesSpark, Trino, DuckDBPipelineparse, clean, MERGE
A lakehouse in one picture: immutable raw files land in object storage, pipelines turn them into Parquet in progressively cleaner layers, a table format's metadata makes the silver and gold layers transactional, a catalog holds the pointer to each table's current snapshot, and every engine reads the same files through that catalog.

The result is the lakehouse: warehouse-like tables (ACID, schema, SQL) on lake storage (cheap, open files, many engines). Spark, Trino, Flink, DuckDB and the major cloud warehouses can all read Iceberg tables in the same bucket, which removes the copy-the-data-into-each-tool problem. A catalog (REST, Glue, Nessie, Unity) holds the pointer from table name to current metadata file; it is the one component every engine must agree on.

Warehouse, lake, or lakehouse

A cloud warehouse (BigQuery, Snowflake, Redshift, and similar) is the simplest to run: load data, write SQL, pay for storage and compute, get performance without managing files. A data lake is object storage with files and a query engine on top: cheapest and most flexible, most operational work, historically weakest on consistency. A lakehouse is the lake with table formats, closing most of the gap. The choice is less about technology than about the team: who operates it, which tools must read the data, how much data there is, and whether machine learning and streaming workloads need direct file access.

  • Small team, mostly SQL analytics, moderate data: a warehouse, with Parquet exports to a lake for ML and archive.
  • Many engines (Spark for ML, Trino for ad hoc, Flink for streams) over the same data: a lakehouse with Iceberg and a shared catalog.
  • Regulatory needs for deletion and audit: a table format with row-level delete and snapshot history, whichever platform.
  • Whatever you pick: an immutable raw layer, Parquet as the interchange format, and business models defined in code.
Tip

Most warehouses now read Iceberg tables directly and some write them. Designing the lake layer with an open table format keeps the option to change engines without a migration project.

Hands-on practice

From CSV to a time-travelling table

  1. Generate a five-million-row orders CSV with Python (random dates over a year, a few hundred customers). Time SELECT SUM(amount) WHERE ordered_at >= '2026-03-01' over the CSV in DuckDB.
  2. Convert it to a single Parquet file with zstd compression, then to a Parquet dataset partitioned by month. Time the same query on each and record file sizes.
  3. Inspect the partitioned files with pyarrow.parquet: row groups, statistics for ordered_at. Rewrite one month sorted by ordered_at and compare the min/max spread per row group.
  4. Deliberately write the same month as 500 tiny files (chunked writes) and time the query; then compact them and time again.
  5. Install PyIceberg (or use Spark/DuckDB with the Iceberg extension) with a local SQLite or REST catalog. Create an Iceberg table from the Parquet data with day partitioning on ordered_at.
  6. Append a second batch, then run a delete for one customer. List the snapshots and query the table as of the first snapshot to confirm the deleted rows are still visible there.
  7. Add a column to the table's schema without rewriting data, write a batch with the new column, and read the whole table back.
Cheat sheet

Storage formats & the lakehouse — at a glance

Main things to focus on

  • Columnar for scans over many rows and few columns; row-oriented for whole-record reads and writes
  • Parquet: row groups with min/max statistics enable predicate pushdown; sort by filter columns; 128 MB to 1 GB files, zstd
  • Partition object storage by event date at a grain that yields hundreds of MB per partition; compact small files
  • Immutable raw layer, then progressively cleaner layers; everything below raw is rebuildable
  • Table formats add ACID commits, snapshot isolation, MERGE/DELETE, schema and partition evolution, time travel
  • Choose warehouse vs lakehouse by team and engines; keep Parquet and an open table format as the interchange

Formats

CSV / JSONLRow-oriented, untyped; ingest only
Parquet / ORCColumnar, typed, compressed, statistics
AvroRow-oriented with schema; streaming and records
compression: zstd (ratio) or snappy (speed)Both splittable inside Parquet
dictionary + run-length encodingWhy columns compress so well

Parquet internals

file -> row groups -> column chunks -> pagesStructure
footer: schema + per-chunk min/max/nullsRead first; drives skipping
predicate pushdown / projection pushdownSkip row groups / read only needed columns
pq.ParquetFile(p).metadata.row_group(i).column(j).statisticsInspect what readers see
row_group_size ~128 MB; sort by filter columnsTight statistics, effective skipping

Lake layout

table/dt=YYYY-MM-DD/part-N.parquetDate partitioning by event time
raw -> bronze -> silver -> goldImmutable source, then cleaner layers
avoid > ~10k files per table; compact weeklyListing and footer costs
partition size: 100s of MB to a few GBNot by hour, not by high-cardinality keys
raw retained compressed, never modifiedThe replay source

Table formats

USING iceberg PARTITIONED BY (days(ts))Hidden partitioning; filter on ts directly
MERGE INTO t USING s ON ... WHEN MATCHED UPDATE / NOT MATCHED INSERTUpsert into a lake table
SELECT ... TIMESTAMP AS OF / VERSION AS OFTime travel
rewrite_data_files / expire_snapshotsCompaction and metadata cleanup
catalog: REST / Glue / Nessie / UnityTable name -> current metadata pointer
schema evolution: add/rename/drop in metadataNo data rewrite

Common pitfalls

  • Partitioning by hour or by a high-cardinality key and creating a million tiny files.
  • Writing CSV between pipeline stages, losing types and paying for full scans forever.
  • Never compacting, so the table gets slower every day even though data volume is flat.
  • Modifying the raw layer, so the one source that could rebuild everything is now suspect.
  • Two jobs writing the same Parquet folder without a table format, corrupting each other.
  • Partitioning by load date when queries filter by event date.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →