Skip to main content

What Iceberg Solves: Snapshots, Manifests, and the Metadata Tree

Level 7: Warehouses, Lakehouse & Dimensional Modelingmedium28 minopen table formatsACID on object storagesnapshotsmanifestsmetadata tableshidden partitioningNULL filtering

What an open table format adds on top of raw Parquet in object storage, and how to read Iceberg's snapshot and file metadata to see exactly which files a query would scan.

Where "files plus a catalog" runs out

Level 6 built a table out of two things: Parquet files in a bucket, and a catalog entry saying where they live and what the columns are. That gets you a queryable table for almost nothing, and it is genuinely how a lake starts. It also has three holes that show up the first week a second person touches the data.

  • No transactions. A job that rewrites a partition deletes some files and writes others. A reader that lands in the middle sees half the old data and half the new. There is no commit, so there is nothing to be atomic about.
  • No safe concurrent writers. Two jobs writing the same table race on the same prefix. The catalog has no idea a write is in progress, so last-writer-wins silently destroys work.
  • A frozen layout, and no way back. The partition scheme is baked into the folder paths, so changing from daily to hourly means rewriting every path in the table. And once a file is overwritten, yesterday's answer is gone. You cannot reproduce the number you put in a dashboard last Tuesday.

An open table format fixes exactly these. Files stay Parquet in object storage, and any engine can still read them, but a layer of metadata turns the pile of files into a table with commits.

The metadata tree

Iceberg's whole idea is that a table is defined by metadata, not by whatever happens to be sitting under a prefix. The catalog stores one pointer: the current metadata file. Everything else hangs off it.

Order of evaluation
  1. Catalogone pointer: current metadata file
  2. Snapshotone commit; schema + partition spec
  3. Manifest listthe manifests in this snapshot
  4. Manifestdata files + per-file stats
  5. Data filesordinary Parquet in the bucket
A read starts at the catalog pointer and walks down. Nothing in this path lists the bucket.

Read it top down and the properties fall out:

  • A snapshot is one commit: the complete set of data files that made up the table at that instant. Committing means writing new metadata and then swapping the catalog pointer to it in a single atomic operation. That swap is the transaction. Readers already holding the old pointer keep reading a consistent older snapshot, so a writer never tears a reader in half.
  • A manifest lists data files together with per-file statistics: row count, file size, and column lower and upper bounds. Those stats are the reason a planner can throw away files before opening any of them.
  • Readers never list the bucket. On a plain lake, planning a query means asking object storage for every key under a prefix, which is slow and gets slower as the table grows. Iceberg reads a manifest instead, so planning cost tracks the number of files in the metadata, not the number of objects in the bucket.

Hidden partitioning and schema evolution

Two features come up constantly and each is one paragraph of understanding.

Hidden partitioning. On a Hive-style lake the partition value is part of the path, so the query has to filter on the partition column by hand (WHERE dt = '2026-07-01'), and a user who filters on the raw timestamp instead gets a full scan. Iceberg stores the partition rule as a transform of a real column (for example days(event_ts)), records the derived value in the manifest, and applies it for you. You filter on event_ts and pruning still happens. Because the rule lives in metadata rather than in paths, you can also change it later (partition evolution): new data is written under the new scheme, old data keeps the old one, and both are still readable.

Schema evolution by field id. Every column carries a numeric field id, and data files reference ids, not names. Renaming a column is a metadata edit. Adding, dropping, and reordering columns never require rewriting files, and a dropped-then-re-added column cannot resurrect old values, because the new column gets a new id.

Delta, Hudi, and who won

Iceberg is the de facto open standard: vendor-neutral governance and the broadest engine support (Spark, Flink, Trino, Snowflake, BigQuery, DuckDB, AWS S3 Tables). The v3 spec was ratified in mid-2025, adding deletion vectors, a variant type, and geospatial types. Delta Lake remains strong inside Databricks, and its UniForm feature emits Iceberg-readable metadata beside Delta's own. Hudi holds a streaming and CDC niche. The pattern worth naming in an interview is the consumption gravity: every non-Iceberg format now ships an Iceberg-readable surface, and the reverse investment does not exist.

Worth keeping in proportion: in the 2026 State of Data Engineering Survey (n = 1,101), 43.8% of teams run a cloud warehouse, 26.8% a lakehouse, and 11.7% a hybrid. The warehouse still dominates entry-level work. Lakehouse literacy is expected, not lakehouse everywhere.

Reading it with SQL

The payoff for this level is that Iceberg exposes its own metadata as ordinary tables you can query. snapshots gives you the commit history. files gives you every data file, the snapshot that added it, and the snapshot that stopped pointing at it. A file with no deleting snapshot is live right now, which makes "what would this query scan" a plain WHERE clause.

Common mistake: reading the files table without filtering deleted_snapshot_id IS NULL and reporting the result as the table's size. That set includes every file the table ever wrote, including the ones replaced by later commits, so the number can be several times the live footprint.

Interview nuance: the junior bar for "what does Iceberg actually solve" is the problem statement, not the spec. One sentence covers it: ACID transactions, schema evolution, and time travel over Parquet files in object storage, with a catalog pointer telling engines which files are current. Catalog wars and manifest internals are senior awareness, and volunteering them before the one-sentence answer reads as memorized.

On a real platform this differs. These seeds mirror Iceberg's real metadata tables. In Spark or Athena you query them directly with SELECT * FROM analytics.orders.snapshots and SELECT * FROM analytics.orders.files, and snapshot_id, parent_id, committed_at, operation, file_path, record_count, and file_size_in_bytes carry exactly these names. Four columns here are a teaching simplification, and knowing which ones saves you a column-not-found error on your first real query. Real snapshot ids are large random longs, so production code walks the parent_id chain or compares committed_at rather than using <. The real snapshots table has no added_files or deleted_files column; those counts are string entries in its summary map, read as summary['added-data-files'] and summary['deleted-data-files']. The real files table exposes partition as a struct of the partition-spec fields rather than a flat partition_dt. And it carries no added_snapshot_id or deleted_snapshot_id at all: it shows one snapshot's live files at a time, and the commit that added or dropped a file is reachable through the entries and all_entries metadata tables.

Sample data for this example
CREATE TABLE snapshots (
  snapshot_id   INTEGER,
  parent_id     INTEGER,   -- the snapshot this commit was built on; NULL for the first commit
  committed_at  TEXT,
  operation     TEXT,      -- append | overwrite | delete | replace
  added_files   INTEGER,   -- data files this commit added
  deleted_files INTEGER    -- data files this commit stopped pointing at
);
INSERT INTO snapshots (snapshot_id, parent_id, committed_at, operation, added_files, deleted_files) VALUES
  (1001, NULL, '2026-06-28 02:10:00', 'append',    4, 0),
  (1002, 1001, '2026-06-29 02:10:00', 'append',    3, 0),
  (1003, 1002, '2026-06-30 02:12:00', 'append',    3, 0),
  (1004, 1003, '2026-07-01 02:11:00', 'append',    4, 0),
  (1005, 1004, '2026-07-01 09:40:00', 'overwrite', 2, 3),
  (1006, 1005, '2026-07-02 02:10:00', 'delete',    0, 2),
  (1007, 1006, '2026-07-02 11:05:00', 'append',    2, 0),
  -- rewrite_manifests: a real commit that reorganizes metadata only, so no data file
  -- was added and none was dropped. It is still a snapshot you can time travel to.
  (1008, 1007, '2026-07-02 11:40:00', 'replace',   0, 0);

CREATE TABLE files (
  file_path           TEXT,
  partition_dt        TEXT,     -- the hidden-partition value Iceberg derived from the event date
  record_count        INTEGER,
  file_size_in_bytes  INTEGER,
  added_snapshot_id   INTEGER,  -- the commit that first pointed at this file
  deleted_snapshot_id INTEGER   -- the commit that stopped pointing at it; NULL means still live
);
INSERT INTO files (file_path, partition_dt, record_count, file_size_in_bytes, added_snapshot_id, deleted_snapshot_id) VALUES
  ('s3://lake/warehouse/orders/data/dt=2026-06-28/00001-1-4b2e.parquet', '2026-06-28', 520000, 134217728, 1001, 1006),
  ('s3://lake/warehouse/orders/data/dt=2026-06-28/00002-1-91af.parquet', '2026-06-28', 548000, 141557760, 1001, 1006),
  ('s3://lake/warehouse/orders/data/dt=2026-06-28/00003-1-c0d7.parquet', '2026-06-28', 502000, 129761280, 1001, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-28/00004-1-e83b.parquet', '2026-06-28', 460000, 118489088, 1001, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-29/00001-2-7a19.parquet', '2026-06-29', 588000, 151257088, 1002, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-29/00002-2-2f6c.parquet', '2026-06-29', 561000, 144703488, 1002, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-29/00003-2-b451.parquet', '2026-06-29', 512000, 132120576, 1002, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-30/00001-3-d20a.parquet', '2026-06-30', 623000, 160432128, 1003, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-30/00002-3-5e77.parquet', '2026-06-30', 582000, 149946368, 1003, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-06-30/00003-3-a3c8.parquet', '2026-06-30', 537000, 138412032, 1003, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00001-4-6b90.parquet', '2026-07-01', 602000, 155189248, 1004, 1005),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00002-4-f17d.parquet', '2026-07-01', 574000, 147849216, 1004, 1005),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00003-4-8c25.parquet', '2026-07-01', 529000, 136314880, 1004, 1005),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00004-4-3ed1.parquet', '2026-07-01', 472000, 121634816, 1004, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00005-5-9ba4.parquet', '2026-07-01', 782000, 201326592, 1005, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-07-01/00006-5-c5e2.parquet', '2026-07-01', 655000, 168820736, 1005, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-07-02/00001-7-e91f.parquet', '2026-07-02', 436000, 112197632, 1007, NULL),
  ('s3://lake/warehouse/orders/data/dt=2026-07-02/00002-7-40ab.parquet', '2026-07-02', 407000, 104857600, 1007, NULL);
Worked example (SQL)
-- The live file set: every data file the current snapshot still points at.
-- A NULL deleted_snapshot_id is the whole test.
SELECT file_path, partition_dt, record_count, file_size_in_bytes
FROM files
WHERE deleted_snapshot_id IS NULL
ORDER BY partition_dt, file_path;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the live data files a query on partition dt = '2026-07-01' would scan, as (file_path, file_size_in_bytes), largest file first, over files(file_path, partition_dt, record_count, file_size_in_bytes, added_snapshot_id, deleted_snapshot_id).

A file is live when deleted_snapshot_id is NULL. Keep only partition_dt = '2026-07-01' and order by file_size_in_bytes descending.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a query that returns the table's commit history as (snapshot_id, operation, added_files, deleted_files, net_files, committed_at), oldest commit first, over snapshots(snapshot_id, parent_id, committed_at, operation, added_files, deleted_files), where net_files is the net change that commit made to the count of live data files.

Keep only the commits that actually changed the live file set, so a commit that added nothing and deleted nothing is left out. Order by committed_at and alias the columns exactly as listed.

4 hints and 1 automated check are waiting in the workspace.