All posts

The three tiers of an Iceberg table: catalog, metadata, data

An Iceberg table is three layers with one job each: a pointer, a tree that describes files, and the files. Knowing which layer a problem lives in is most of operating one, because each has its own growth rate and its own maintenance job.

9 min read Iceberg

TL;DR

  • Three tiers: the catalog holds one pointer, the metadata tier describes which files are in the table, the data tier holds rows.
  • The metadata tier is itself three levels — metadata.json → manifest list → manifests — and each commit writes one of each. Measured: 2 → 5 → 8 → 11 files across three appends.
  • A minimal table is 5 objects: one Parquet file, two metadata.json, one manifest list, one manifest.
  • Manifests carry per-file column bounds keyed by field id, which is what turns metadata into a query accelerator rather than bookkeeping.
  • Each tier has its own maintenance: data → compaction, metadata → manifest rewriting and expiry, catalog → nothing, which is why it must be reliable.

The three tiers

flowchart TB
    subgraph T1["catalog tier"]
      C["one pointer per table"]
    end
    subgraph T2["metadata tier"]
      M["metadata.json<br/>schema, specs, snapshots"] --> ML["manifest list<br/>snap-*.avro"]
      ML --> MF["manifests<br/>*.avro, per-file stats"]
    end
    subgraph T3["data tier"]
      D["*.parquet"]
      DEL["delete files"]
    end
    C --> M
    MF --> D
    MF --> DEL

The three tiers of an Iceberg table: a catalog holding one pointer, a metadata tier of metadata.json, manifest list and manifests carrying per-file statistics, and a data tier of Parquet data files and delete files

Each tier answers one question. The catalog: which metadata file is current? The metadata tier: which data files are in the table, and what is in them? The data tier: the rows.

Almost every operational question resolves to “which tier is this?” — and the answer determines the fix.

Tier 1: the catalog

One pointer, swapped atomically. That is the whole tier, and its smallness is the point: the less it does, the easier it is to make correct.

It is also the only tier that cannot be rebuilt from the others. Lose the data and you have lost rows; lose the metadata and you have lost history; lose the catalog and you have lost which metadata file was current — which, with a directory full of metadata.json files, is recoverable but manual.

Its implementation decides whether concurrent commits are safe, and it is visible in the filenames the metadata tier produces. That is its own subject.

Tier 2: metadata, in three levels

metadata.json

The table’s state: current schema and every past schema, every partition spec, the snapshot list, table properties, and the pointer to each snapshot’s manifest list. One is written per commit, never edited.

format-version = 2
write.parquet.compression-codec = zstd

Schemas are kept plurally because old snapshots were written under old schemas, and time travel has to read them correctly.

The manifest list

One per snapshot, an Avro file naming the manifests that make up that snapshot — with a summary of each, including its partition range:

manifest af2b689e-...-m0.avro files=1
    partition_summaries=[lower_bound='2026-01-07', upper_bound='2026-01-07']
manifest aef10e73-...-m0.avro files=1
    partition_summaries=[lower_bound='2026-01-06', upper_bound='2026-01-06']

Those summaries let a planner discard an entire manifest — and every file in it — without opening it.

This level is also what makes snapshots cheap: a new manifest list points at unchanged manifests from the previous snapshot rather than copying them, so successive snapshots share almost all their metadata.

Manifests

The leaf level, listing data files with statistics:

file 00000-5-...parquet  rows=3000  partition=Row(event_ts_day=2026-01-06)
    lower_bounds={1: ..., 2: ..., 3: 'r0', 4: ...}
    upper_bounds={1: ..., 2: ..., 3: 'r2', 4: ...}

Record count, partition value, and per-column min/max keyed by field id. This is where metadata stops being bookkeeping and becomes a query accelerator: a filter that cannot match a file’s bounds skips the file unopened.

Each manifest also records the spec_id its files were written under, which is what lets partition layouts change without rewriting history.

Tier 3: data

Parquet (or ORC or Avro) holding rows, plus delete files on v2 and later marking rows removed without rewriting.

Nothing here is Iceberg-specific — the Parquet is ordinary Parquet. The table format is entirely the tiers above it, which is why migrating a Hive table is a metadata operation and why any engine can read the data once told which files to read.

What a table costs

A table with 5,000 rows written once, listed from object storage:

10KiB  db/trips/data/pickup_day=2026-02-10/00000-2-....parquet
 922B  db/trips/metadata/00000-fbb1be3d-....metadata.json
2.0KiB db/trips/metadata/00001-9804ab23-....metadata.json
7.3KiB db/trips/metadata/c4295385-....-m0.avro
4.3KiB db/trips/metadata/snap-7554101406213169966-1-c4295385-....avro

Five objects: one data, four metadata. Two metadata.json because there were two commits — the create and the append.

And the growth rate, across three appends to a partitioned table:

after CREATE, before any data   metadata_files=2    data_files=0
after append #1                 metadata_files=5    data_files=1
after append #2                 metadata_files=8    data_files=2
after append #3                 metadata_files=11   data_files=3

Three metadata files per commit, regardless of how many rows it carried. For a streaming table this is the number that matters: metadata grows with commit frequency, not data volume.

Reading each tier from SQL

Every tier is queryable, which makes “which tier is my problem in” a question you answer rather than guess.

-- TIER 1: what the catalog points at, and every pointer it has held
SELECT file, latest_snapshot_id, timestamp
FROM local.db.events.metadata_log_entries ORDER BY timestamp DESC LIMIT 5;

-- TIER 2a: snapshots, and what each commit did
SELECT snapshot_id, parent_id, operation,
       summary['added-data-files'] AS added,
       summary['total-records']    AS total
FROM local.db.events.snapshots ORDER BY committed_at;

-- TIER 2b: how much metadata planning must read
SELECT count(*) AS manifests, sum(added_data_files_count) AS files
FROM local.db.events.manifests;

-- TIER 3: the data itself, split by content type
SELECT content, count(*) AS files, sum(record_count) AS records,
       sum(file_size_in_bytes) AS bytes
FROM local.db.events.files GROUP BY content ORDER BY content;

That last query is the single most useful one on a production table. content is 0 for data, 1 for position deletes and 2 for equality deletes, so one row tells you whether you have a small-file problem, a delete-accumulation problem, or neither.

The growth rates differ, and that is the point

Each tier grows in response to something different, which is why one maintenance job cannot cover all three.

Tier Grows with Bounded by
catalog nothing —
metadata.json commits write.metadata.previous-versions-max
manifest lists snapshots expire_snapshots
manifests commits, minus automatic merging rewrite_manifests
data files writes rewrite_data_files
delete files row-level operations rewrite_position_delete_files

Read the middle column. A table that receives one large daily load has few commits and grows slowly in every metadata dimension. A table receiving a commit per minute grows metadata 1,440 times a day regardless of how many rows those commits carried — which is why a low-volume streaming table can develop a metadata problem that a high-volume batch table never does.

A worked example of diagnosis

A table where “queries got slower over the last month, and the data has not grown”. Work down the tiers.

-- 1. is it data, or metadata?
SELECT count(*) FROM local.db.events.manifests;          -- say 2,400
SELECT count(*) FROM local.db.events.files;              -- say 3,100
SELECT count(*) FROM local.db.events.snapshots;          -- say 43,000

Forty-three thousand snapshots against three thousand data files is the signature: this table’s problem is history, not data. Planning is reading 2,400 manifests, and nothing has ever been expired.

-- 2. confirm nothing is reclaimable yet
SELECT min(committed_at), max(committed_at) FROM local.db.events.snapshots;

If the oldest snapshot is months old, expiry has never run.

-- 3. fix in order: compact metadata, then reclaim
CALL local.system.rewrite_manifests(table => 'db.events');
CALL local.system.expire_snapshots(
  table => 'db.events', older_than => TIMESTAMP '2026-09-10 00:00:00', retain_last => 20);

The opposite signature — few snapshots, tens of thousands of data files — is a data problem and wants rewrite_data_files instead. The two look identical from a dashboard showing query duration, and the metadata tables are what tell them apart.

Which tier is your problem in?

Symptom Tier Fix
Tasks slow, many small files data rewrite_data_files
Reads slow on a CDC table data (delete files) rewrite_position_delete_files
Slow before any task starts metadata (manifests) rewrite_manifests
Storage grows, rows flat metadata (snapshots) expire_snapshots
Metadata dir has 10,000 JSONs metadata (metadata.json) write.metadata.* retention
Commits failing or lost catalog fix the catalog choice

The distinction between “slow before tasks start” and “slow tasks” is the diagnostic that matters most. The first is metadata — planning is reading too many manifests. The second is data — too many files or too many deletes. They look identical in a dashboard showing query duration and have different fixes.

Note that metadata.json accumulation is governed separately from snapshot expiry. Expiring snapshots does not remove old metadata files; write.metadata.delete-after-commit.enabled does.

Common misconceptions

“Metadata is small.” Four of the five objects in a fresh table are metadata, and it grows per commit rather than per row.

“One snapshot means one copy of the metadata.” Snapshots share manifests. A thousand snapshots do not cost a thousand times one.

“The catalog stores the schema.” The schema is in metadata.json. The catalog stores which one is current.

“Expiring snapshots cleans the metadata directory.” It removes snapshots and their exclusive files; metadata.json files have their own retention.

“Data files are special.” They are ordinary Parquet. All the structure is above them.

A model worth keeping

A pointer, a tree, and some Parquet.

The pointer must be atomic, the tree must stay small enough to read quickly, and the Parquet must stay large enough to read efficiently. Each has a maintenance job, and diagnosing a slow table starts with asking which of the three has grown out of shape.

References

Trademarks

Apache Iceberg, Apache Spark, Apache Parquet, Apache Avro, Apache ORC, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.

Found this useful?

These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.

Buy me a coffee