Module: Advanced | Duration: ~30 min | Lesson: 1 of 9
TheWorldShop's orders table has grown to 8 billion rows across 400,000 Parquet files. A query planner needs to answer: "Which of these 400,000 files might contain orders from the US placed in January 2024?"
A naive approach: list all 400,000 files, read each file's footer to check its statistics. That's 400,000 network round trips. Minutes of planning overhead before reading a single byte of data.
Iceberg answers this question in under a second. How? The answer is in how the table is structured: a tree of immutable files that prunes work at every layer. This lesson is where you understand that structure cold, top to bottom, down to the byte-level details.
2. Concept Explanation
The Complete Structure: Five Layers
An Iceberg table isn't a directory of data files. It's a tree of immutable files behind a single pointer. Nothing is ever edited in place. Every commit writes new files and moves the pointer. The whole table is five layers, top to bottom:
The catalog (Layer 0) is the only mutable thing in the whole system. Everything beneath it is content-addressed by path and never changes. Layers 1 to 3 are the focus of this lesson because that's where scan planning happens. We cover the two bookends too: Layer 0 here and in the commit protocol below, Layer 4 right after the manifests.
On disk, that same tree is just files in two folders:
Each layer serves a different purpose in the scan planning pipeline, and each one prunes work before the next is even opened.
Level 1: Table Metadata (metadata.json)
The entry point. Written in JSON for human readability. Contains everything needed to understand the current and historical state of the table.
Key fields (from format/spec.md in the codebase):
When a query engine starts planning, it reads this single file (~50 KB) and knows: current schema, partition spec, and where to find the manifest list.
Level 2: Manifest List (Avro)
One manifest list per snapshot. This is an Avro file: binary, compact, schema-encoded. Each row represents one manifest file. The manifest list's value comes from its partition summary columns: for each partition field, it stores the lower_bound and upper_bound of values across all files in that manifest.
Key manifest list entry fields (from ManifestFile.java):
This is where partition pruning happens. When a query has WHERE order_date = '2024-01-15', Iceberg:
- Computes
days('2024-01-15')=19737 - Scans the manifest list
- For each manifest entry, checks:
lowerBound ≤ 19737 ≤ upperBound - Skips any manifest where the bound check fails
If a manifest covers only data from 2023-12-01 to 2023-12-31, it's skipped entirely, without reading the manifest file.
Level 3: Manifest Files (Avro)
Each manifest file contains one row per data file or delete file. Critically, each row includes per-column statistics (min, max, null count) for every column in the data file.
Key fields per data file entry (DataFile.java):
With these per-file statistics, the query planner can skip files where a filter predicate is provably false:
WHERE order_total > 1000: skip files whereupperBounds[3] ≤ 1000WHERE country = 'US': skip files wherelowerBounds[6] > 'US' OR upperBounds[6] < 'US'
This is data skipping, and it's based entirely on the metadata already in manifests. No file reads needed.
Level 4: Data, Delete, and DV Files
The bottom layer is the files a manifest points at, and there's more down here than just data:
- Data files (Parquet, ORC, or Avro). The rows themselves. A manifest entry marks these with
content = 0. - Position delete files (
content = 1). A list of(file_path, position)tuples saying "row N of that data file is gone." Introduced in v2, deprecated from v3 in favor of deletion vectors. - Equality delete files (
content = 2). A predicate on column values, likecustomer_id = 9999, with no file path or position attached. Ideal for streaming upserts. - Deletion vectors (v3+). A compressed bitmap of deleted positions for one data file, stored as a blob inside a Puffin file. One bitmap per data file, replacing piles of small position-delete files.
A single manifest holds either data files or delete files, never both. That split lets scan planning load all the delete manifests first and know what's deleted before it emits a single data file. How those deletes get written and merged at read time is Lesson 3. Here the point is structural: the bottom layer isn't just "your Parquet." It's data plus the side-files that record what's no longer true.
Sequence Numbers
Sequence numbers arrived in format v2 (Lesson 2 tells that story), and they're the quiet engine behind both streaming reads and correct deletes. Every commit gets a monotonically increasing number, threaded through every layer: the table metadata tracks the highest one assigned, each snapshot records its own, the manifest list records each manifest's number, and each manifest entry carries its file's number. Three things ride on this one counter.
Incremental scans. You can ask "give me everything added after sequence number N", which is how a streaming reader tails a table:
Delete ordering. This is the subtle one, and it's why merge-on-read (Lesson 3) stays correct. An equality delete ("any row where customer_id = 9999") applies to a data file only when the delete's sequence number is greater than the file's. So if you delete 9999 and then insert a new 9999, the insert has a higher sequence number and survives. The delete can't reach forward in time.
Inheritance. A manifest entry can leave its snapshot id and sequence number blank in the file, and the reader fills them in from the manifest list. That sounds like a detail, but it's what keeps commits cheap. When a commit loses the pointer-swap race and retries with a new sequence number, only the small manifest list is rewritten. The manifests and data files it points at are untouched.
The Commit Protocol: One Atomic Swap
Everything below the catalog is immutable and content-addressed by path, so readers and writers build files with no locking. The only contended operation in the whole system is the catalog pointer. A commit from version V to V+1 is two steps: write the new manifests, manifest list, and metadata.json (all new files, with random names), then compare-and-swap the catalog pointer from the old metadata path to the new one. If the swap succeeds, the commit is visible. If it fails, someone else committed first, so the writer rebases on the new head and retries.
The rebase isn't always safe to do blindly, so each commit carries an intent that decides how it replays:
- An append is always replayable. New files never conflict with someone else's new files.
- A replace (compaction) must verify the files it's replacing are still in the table.
- A delete by file must verify its target files still exist.
- A delete by expression is always replayable.
- A schema or spec change must verify no other schema change slipped in first.
This is why two engineers appending to TheWorldShop's orders table at the same moment both succeed, but two compaction jobs fighting over the same files don't silently corrupt each other. One wins the swap. The other rebases, notices its files are already gone, and bails out cleanly.
How Scan Planning Actually Works
Putting it all together, here's the full scan planning pipeline for a query with predicate WHERE order_date = '2024-01-15' AND country = 'US':
Compare to Hive: listing 400,000 files would take minutes.
The Whole Table on One Page
Every layer you've seen, stacked into one mental model:
Read it top to bottom and you have the entire table. The catalog holds one pointer. Everything beneath it is immutable. A new commit appends a new metadata.json plus new manifest-list, manifest, and data files, then swaps the pointer in one atomic step. The old tree stays reachable through snapshot history (that's your time travel) until expiration garbage-collects it.
3. Worked Example
Let's inspect actual metadata files from the Iceberg codebase test fixtures. Here's how you can dump and read them in Spark:
4. Your Turn
Exercise: Given an Iceberg table with these manifest entries:
| Manifest | Date LowerBound | Date UpperBound | Country LowerBound | Country UpperBound | Files |
|---|---|---|---|---|---|
| M1 | 2024-01-01 | 2024-01-31 | 'CA' | 'US' | 500 |
| M2 | 2024-01-01 | 2024-01-31 | 'DE' | 'UK' | 400 |
| M3 | 2024-02-01 | 2024-02-28 | 'CA' | 'US' | 600 |
| M4 | 2024-02-01 | 2024-02-28 | 'AU' | 'JP' | 350 |
Query: WHERE order_date BETWEEN '2024-01-10' AND '2024-01-20' AND country = 'US'
- Which manifests survive the partition (date) pruning step?
- Of those, which survive the file statistics (country) pruning step?
- How many data files does Spark actually need to open?
5. Real-World Application
Netflix's Metacat service (their metadata management platform) is built on top of Iceberg's metadata model. They've described cases where query planning time for their largest tables dropped from 12 minutes (Hive) to 8 seconds (Iceberg) just from the metadata hierarchy redesign.
The Puffin file format (which you'll see in Lesson 6 on performance) extends this even further by allowing pre-computed statistics (like theta sketches for approximate distinct counts) to be stored alongside manifests, enabling query planners to skip not just files but also defer expensive cardinality estimation.
In your career: When a stakeholder says "our Iceberg queries are slow," the first thing to check is scan planning efficiency. Are manifests being pruned? Are per-file statistics being used? The metadata layer is the first place to look, not the compute layer.
Aha: Iceberg's manifest list isn't an index "for" the data. It's an index over per-partition min/max bounds. By the time the query engine touches a manifest file, the partition decision is already made. That's why scan planning stays sub-second even when the file count grows past a million.
6. Recap + Bridge
What we learned: An Iceberg table is five layers: the catalog pointer (Layer 0), metadata.json (Layer 1), the manifest list (Layer 2), manifests (Layer 3), and the data, delete, and deletion-vector files (Layer 4). Layers 1 to 3 carry the partition bounds and per-file statistics that make sub-second scan planning possible on billion-row tables. Sequence numbers order deletes and power incremental reads. A commit appends new files and swaps the single catalog pointer, which is what makes the whole thing atomic.
Coming up next: You've seen the metadata layers as they stand today. But the format reached this shape in four steps, and the version number stamped in every metadata.json decides which of these features a table even has. Lesson 2 walks the evolution from v1 to v4: what each version added, why it was needed, and what it means for a table you have to read.