Apache Iceberg in Practice: Table Format Evolution and Time Travel
The Hive table format (HMS + Parquet/ORC) was the de facto standard in the big data era, but its limitations have become increasingly apparent as data platforms evolve:
“Series: S8 Technology Deep Dives · Article 4 | Level: Advanced | Reading Time: 20 min
Apache Iceberg in Practice: Table Format Evolution and Time Travel
#TL;DR
- Apache Iceberg is the foundational table format for coomia-dip's Lakehouse architecture, delivering ACID transactions, schema evolution, partition evolution, and time-travel queries — solving every major pain point of the legacy Hive table format
- Through Iceberg's three-layer metadata architecture (Catalog → Metadata File → Manifest), coomia-dip achieves efficient snapshot management, incremental reads, and cross-engine data sharing
- This article dissects Iceberg's internals, six practical usage scenarios within coomia-dip, performance tuning strategies, and integration best practices with Nessie, Doris, and Flink
#1. Why Apache Iceberg
#1.1 The Legacy Table Format Dilemma
The Hive table format (HMS + Parquet/ORC) was the de facto standard in the big data era, but its limitations have become increasingly apparent as data platforms evolve:
- Partition coupling: Once defined, partition schemes cannot be changed without rewriting the entire table
- Directory dependency: Partition and file discovery relies on filesystem directory listings, causing massive LIST overhead on object stores
- No transaction support: Concurrent writes can produce inconsistent state; reads may see partially written data
- Limited schema evolution: Only supports appending columns at the end; no rename, delete, or type promotion
- No time travel: No snapshot concept — once data is overwritten, it is permanently lost
#1.2 Iceberg's Design Philosophy
Apache Iceberg takes a fundamentally different approach:
| Dimension | Hive Table | Apache Iceberg |
|---|---|---|
| Metadata | Filesystem directories | Independent metadata files |
| Partition discovery | Directory listing | Metadata index |
| Transactions | None | ACID (optimistic concurrency) |
| Schema evolution | Append-only columns | Full evolution (add/drop/rename/reorder) |
| Partition evolution | Not supported | Online evolution |
| Time travel | Not supported | Snapshot-based |
| Engine binding | Tightly coupled | Engine-agnostic |
#1.3 Iceberg's Position in coomia-dip
┌─────────────────────────────────────────────────┐
│ Query Engine Layer │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌────────────────┐ │
│ │Doris │ │Spark │ │Flink │ │ Trino/Presto │ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──────┬─────────┘ │
│ └────────┴────────┴─────────────┘ │
│ │ │
│ ┌─────────────────▼───────────────────────────┐│
│ │ Nessie Catalog ││
│ │ (Version Control + Metadata Management) ││
│ └─────────────────┬───────────────────────────┘│
│ │ │
│ ┌─────────────────▼───────────────────────────┐│
│ │ Apache Iceberg ││
│ │ (Table Format + Snapshots + Schema) ││
│ │ ││
│ │ metadata.json → manifest-list → manifest ││
│ │ → data files (Parquet) ││
│ └─────────────────┬───────────────────────────┘│
│ │ │
│ ┌─────────────────▼───────────────────────────┐│
│ │ Object Storage (MinIO/S3) ││
│ └──────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
#2. Iceberg's Internal Architecture
#2.1 The Three-Layer Metadata Structure
Iceberg's core innovation is its three-layer metadata architecture, which decouples metadata from the filesystem:
Layer 1: Metadata File (metadata.json)
The top-level metadata file contains the table's complete definition:
{
"format-version": 2,
"table-uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"location": "s3://coomia-dip-lakehouse/warehouse/ontology_db/objects",
"last-sequence-number": 42,
"last-updated-ms": 1711234567890,
"schemas": [
{
"schema-id": 0,
"type": "struct",
"fields": [
{"id": 1, "name": "tenant_id", "type": "string", "required": true},
{"id": 2, "name": "object_rid", "type": "string", "required": true},
{"id": 3, "name": "object_type", "type": "string", "required": true},
{"id": 4, "name": "properties", "type": "string", "required": false},
{"id": 5, "name": "created_at", "type": "timestamptz", "required": true}
]
}
],
"current-schema-id": 0,
"partition-specs": [
{
"spec-id": 0,
"fields": [
{"source-id": 1, "field-id": 1000, "name": "tenant_id", "transform": "identity"},
{"source-id": 5, "field-id": 1001, "name": "created_at_day", "transform": "day"}
]
}
],
"current-snapshot-id": 123456789,
"snapshots": ["..."]
}
Key design decisions:
- Column IDs, not names: Schema evolution tracks fields by integer ID, so renames are free
- Partition transforms: Partitioning is defined by transforms on source columns (day, hour, bucket, truncate), not by separate partition columns
- Multiple schemas: The file stores all historical schemas, enabling backward-compatible reads
Layer 2: Manifest List (snap-*.avro)
Each snapshot points to a manifest list file that catalogs the manifests belonging to that snapshot. Each entry contains:
- Manifest file path
- Partition range summaries (for partition pruning)
- Added/deleted/existing file counts
- Upper and lower bounds for the manifest's content
Layer 3: Manifest Files (*.avro)
Manifests record the actual data files. Each entry includes:
- Data file path (Parquet/ORC/Avro)
- File format and byte size
- Row count
- Column-level statistics (min, max, null count, distinct count)
- Partition values
#2.2 The Snapshot Model
Snapshot 1 (t0) Snapshot 2 (t1) Snapshot 3 (t2)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ ML-001 │ │ ML-002 │ │ ML-003 │
│ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │
│ │ M-A │─┼──► F1,F2 │ │ M-A │─┼──► F1,F2 │ │ M-A │─┼──► F1,F2
│ └──────┘ │ │ ├──────┤ │ │ ├──────┤ │
│ │ │ │ M-B │─┼──► F3 │ │ M-B │─┼──► F3
│ │ │ └──────┘ │ │ ├──────┤ │
│ │ │ │ │ │ M-C │─┼──► F4 (-F2)
└──────────┘ └──────────┘ │ └──────┘ │
└──────────┘
ML = Manifest List, M = Manifest, F = Data File
Each write operation creates a new snapshot. Iceberg supports two mutation strategies:
- Copy-on-Write (COW): Updates rewrite entire data files. Best for batch-heavy, read-intensive workloads.
- Merge-on-Read (MOR): Updates write delete files alongside new data files. Best for write-heavy, CDC-style workloads. Reads must merge at query time.
#2.3 Column-Level Statistics and Query Optimization
Manifest entries contain rich column-level statistics that enable three-tier pruning:
Manifest Entry:
file: "s3://warehouse/data/00001.parquet"
partition: {tenant_id="acme", created_at_day=2026-03-20}
record_count: 50000
column_sizes: {1: 1200000, 2: 800000, 3: 600000}
null_value_counts: {1: 0, 2: 0, 3: 200}
lower_bounds: {1: "acme", 2: "obj-00001", 5: "2026-03-20T00:00:00Z"}
upper_bounds: {1: "acme", 2: "obj-50000", 5: "2026-03-20T23:59:59Z"}
The three pruning tiers:
- Partition pruning: Skip entire partitions based on partition values in the manifest list
- File pruning: Skip data files based on column min/max bounds in manifests
- Row-group pruning: Within Parquet files, skip row groups using Parquet-level statistics
In benchmarks on coomia-dip's ontology object tables, these three levels of pruning reduce I/O by 85-95% for typical filtered queries.
#3. Six Practical Scenarios
#3.1 Scenario 1: ACID Writes for Ontology Objects
# data-Layer/iceberg/ontology_writer.py
from pyiceberg.catalog import load_catalog
from pyiceberg.expressions import EqualTo
class OntologyObjectWriter:
"""Transactional writer for Ontology objects"""
def __init__(self):
self.catalog = load_catalog("nessie", **{
"uri": "http://nessie-server:19120/api/v2",
"ref": "main",
"warehouse": "s3://coomia-dip-lakehouse/warehouse",
})
def upsert_objects(self, tenant_id: str, objects: list[dict]):
"""Atomically upsert a batch of Ontology objects"""
table = self.catalog.load_table("ontology_db.ontology_objects")
with table.transaction() as txn:
# Delete old versions
txn.delete(EqualTo("tenant_id", tenant_id))
# Write new versions
import pyarrow as pa
df = pa.Table.from_pylist(objects)
txn.append(df)
# Transaction commits atomically
#3.2 Scenario 2: Schema Evolution
Iceberg uses column IDs (not column names) to track fields, which enables powerful schema evolution:
# data-Layer/iceberg/schema_evolution.py
class IcebergSchemaEvolution:
"""Schema evolution operations"""
def add_derived_property(self, table_name: str, prop_name: str, data_type: str):
"""Add a derived property column to an Ontology type"""
table = self.catalog.load_table(table_name)
with table.update_schema() as update:
update.add_column(prop_name, data_type, doc=f"Derived: {prop_name}")
def rename_property(self, table_name: str, old_name: str, new_name: str):
"""Rename a property column (existing data files unchanged)"""
table = self.catalog.load_table(table_name)
with table.update_schema() as update:
update.rename_column(old_name, new_name)
def widen_type(self, table_name: str, column: str, new_type: str):
"""Widen a column type (e.g., int → long)"""
table = self.catalog.load_table(table_name)
with table.update_schema() as update:
update.update_column(column, new_type)
Because Iceberg tracks by column ID:
- Renaming a column does not affect existing data files
- Dropping a column only removes the mapping; data files remain unchanged
- Adding a column is transparent to old files — queries return null for the new column
#3.3 Scenario 3: Partition Evolution
# data-Layer/iceberg/partition_evolution.py
class PartitionEvolution:
"""Online partition strategy evolution"""
def evolve_to_hourly(self, table_name: str):
"""Evolve from daily to hourly partitioning"""
table = self.catalog.load_table(table_name)
# Old data keeps daily partitions; new data uses hourly
with table.update_spec() as update:
update.remove_field("created_at_day")
update.add_field(
source_column_name="created_at",
transform="hour",
name="created_at_hour",
)
# No historical data rewrite required!
def add_bucket_partition(self, table_name: str, column: str, num_buckets: int):
"""Add bucket partitioning for high-cardinality columns"""
table = self.catalog.load_table(table_name)
with table.update_spec() as update:
update.add_field(
source_column_name=column,
transform=f"bucket[{num_buckets}]",
name=f"{column}_bucket",
)
Partition evolution is one of Iceberg's most powerful features. In traditional Hive tables, changing the partition scheme requires rewriting all data files and extended downtime. Iceberg allows new data to use a new partition spec while old data retains its original spec. Query engines handle mixed partition specs automatically through the partition-spec-id in metadata.
#3.4 Scenario 4: Time-Travel Queries
# data-Layer/iceberg/time_travel.py
class TimeTravelQuery:
"""Time-travel query operations"""
def query_at_snapshot(self, table_name: str, snapshot_id: int):
"""Query data at a specific snapshot version"""
table = self.catalog.load_table(table_name)
scan = table.scan(snapshot_id=snapshot_id)
return scan.to_pandas()
def query_at_timestamp(self, table_name: str, timestamp_ms: int):
"""Query data as it existed at a specific timestamp"""
table = self.catalog.load_table(table_name)
target_snapshot = None
for snapshot in table.metadata.snapshots:
if snapshot.timestamp_ms <= timestamp_ms:
target_snapshot = snapshot
if target_snapshot:
scan = table.scan(snapshot_id=target_snapshot.snapshot_id)
return scan.to_pandas()
def incremental_read(self, table_name: str, from_snapshot: int, to_snapshot: int):
"""Read only the changes between two snapshots"""
table = self.catalog.load_table(table_name)
scan = table.scan(
snapshot_id=to_snapshot,
options={"start-snapshot-id": str(from_snapshot)},
)
return scan.to_pandas()
#3.5 Scenario 5: Flink CDC Real-Time Ingestion into Iceberg
// data-Layer/flink/IcebergCDCSink.java
public class IcebergCDCSink {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60000);
MySqlSource<String> source = MySqlSource.<String>builder()
.hostname("mysql-source")
.port(3306)
.databaseList("business_db")
.tableList("business_db.orders")
.deserializer(new JsonDebeziumDeserializationSchema())
.build();
DataStream<RowData> cdcStream = env
.fromSource(source, WatermarkStrategy.noWatermarks(), "MySQL CDC")
.map(new CdcToRowDataMapper());
Map<String, String> catalogProps = Map.of(
"type", "iceberg",
"catalog-impl", "org.apache.iceberg.nessie.NessieCatalog",
"uri", "http://nessie-server:19120/api/v2",
"ref", "main",
"warehouse", "s3://coomia-dip-lakehouse/warehouse"
);
TableLoader tableLoader = TableLoader.fromCatalog(
CatalogUtil.buildIcebergCatalog("nessie", catalogProps, new Configuration()),
TableIdentifier.of("ontology_db", "orders")
);
FlinkSink.forRowData(cdcStream)
.tableLoader(tableLoader)
.equalityFieldColumns(List.of("order_id"))
.upsert(true)
.build();
env.execute("CDC to Iceberg");
}
}
#3.6 Scenario 6: Doris Federated Queries on Iceberg
-- Create an Iceberg catalog in Doris
CREATE CATALOG iceberg_catalog PROPERTIES (
"type" = "iceberg",
"iceberg.catalog.type" = "rest",
"iceberg.catalog.uri" = "http://nessie-server:19120/api/v2",
"iceberg.catalog.warehouse" = "s3://coomia-dip-lakehouse/warehouse",
"s3.endpoint" = "http://minio:9000",
"s3.access_key" = "${MINIO_ACCESS_KEY}",
"s3.secret_key" = "${MINIO_SECRET_KEY}"
);
-- Federated query: hot data in Doris, cold data in Iceberg
SELECT
d.object_type,
d.display_name,
i.historical_value,
i.snapshot_time
FROM doris_catalog.ontology_db.hot_objects d
JOIN iceberg_catalog.ontology_db.cold_objects i
ON d.object_rid = i.object_rid
WHERE d.tenant_id = 'acme'
AND i.snapshot_time >= '2026-01-01';
#4. Performance Tuning Strategies
#4.1 Write Optimization
| Strategy | Description | Use Case |
|---|---|---|
| Target file size | Set write.target-file-size-bytes=134217728 (128MB) | General |
| Fanout write | write.distribution-mode=hash | High-cardinality partitions |
| Sorted write | write.distribution-mode=range | Range query optimization |
| Small file merge | Schedule regular compaction jobs | After streaming ingestion |
#4.2 Read Optimization
# data-Layer/iceberg/read_optimization.py
class IcebergReadOptimizer:
"""Read optimization configuration"""
def configure_scan(self, table, filters: dict):
scan = table.scan()
# 1. Column pruning — read only needed columns
scan = scan.select("object_rid", "display_name", "properties")
# 2. Predicate pushdown — skip files using statistics
if "tenant_id" in filters:
scan = scan.filter(EqualTo("tenant_id", filters["tenant_id"]))
# 3. Partition pruning — skip irrelevant partitions
if "created_at" in filters:
scan = scan.filter(
GreaterThanOrEqual("created_at", filters["created_at"])
)
return scan
#4.3 Table Maintenance Operations
# data-Layer/iceberg/table_maintenance.py
class IcebergTableMaintenance:
"""Periodic table maintenance"""
def compact_small_files(self, table_name: str):
"""Merge small files (fixes fragmentation from streaming writes)"""
table = self.catalog.load_table(table_name)
table.rewrite_data_files(
target_size_in_bytes=128 * 1024 * 1024,
min_file_size_bytes=64 * 1024 * 1024,
max_file_size_bytes=256 * 1024 * 1024,
)
def expire_snapshots(self, table_name: str, retention_days: int = 30):
"""Expire old snapshots to reclaim storage"""
table = self.catalog.load_table(table_name)
cutoff = datetime.now() - timedelta(days=retention_days)
table.expire_snapshots().expire_older_than(
int(cutoff.timestamp() * 1000)
).commit()
def remove_orphan_files(self, table_name: str):
"""Clean up files not referenced by any snapshot"""
table = self.catalog.load_table(table_name)
table.remove_orphan_files(
older_than=int((datetime.now() - timedelta(days=3)).timestamp() * 1000)
)
def rewrite_manifests(self, table_name: str):
"""Rewrite manifest files to optimize query planning"""
table = self.catalog.load_table(table_name)
table.rewrite_manifests()
#4.4 Critical Configuration Parameters
| Parameter | Default | Recommended | Notes |
|---|---|---|---|
write.target-file-size-bytes | 512MB | 128MB | Smaller files improve incremental queries |
write.metadata.delete-after-commit.enabled | false | true | Auto-clean old metadata files |
write.metadata.previous-versions-max | 100 | 20 | Limit metadata history versions |
read.split.target-size | 128MB | 256MB | Read split size for query parallelism |
commit.retry.num-retries | 4 | 10 | Retry count for concurrent write conflicts |
commit.retry.min-wait-ms | 100 | 200 | Minimum retry wait time |
#5. Migration from Hive Tables
#5.1 In-Place Migration
-- Spark SQL: migrate a Hive table to Iceberg in-place
CALL nessie.system.migrate('ontology_db.legacy_objects');
-- Verify migration
DESCRIBE EXTENDED nessie.ontology_db.legacy_objects;
#5.2 Shadow Table Migration for Zero Downtime
For production tables that cannot tolerate downtime, use a dual-write strategy:
# data-Layer/iceberg/migration.py
class HiveToIcebergMigration:
"""Incremental Hive-to-Iceberg migration"""
def shadow_table_migration(self, hive_table: str, iceberg_table: str):
"""Shadow table pattern: dual-write until validation passes"""
# 1. Create Iceberg shadow table (same schema)
self._create_shadow_table(hive_table, iceberg_table)
# 2. Full sync historical data
self._full_sync(hive_table, iceberg_table)
# 3. Start incremental sync (CDC dual-write)
self._start_incremental_sync(hive_table, iceberg_table)
# 4. Validate data consistency
self._validate_consistency(hive_table, iceberg_table)
# 5. Switch read routing
self._switch_reads(iceberg_table)
# 6. Stop Hive writes
self._stop_hive_writes(hive_table)
#6. Monitoring and Operations
#6.1 Critical Monitoring Metrics
| Metric | Alert Threshold | Description |
|---|---|---|
| Snapshot count | > 1000 | Run expire_snapshots |
| Manifest file count | > 500 | Run rewrite_manifests |
| Small file ratio (< 10MB) | > 30% | Run compaction |
| Metadata file size | > 10MB | Clean history versions |
| Orphan file count | > 0 | Run remove_orphan_files |
#6.2 Automated Maintenance Schedule
# data-Layer/iceberg/auto_maintenance.py
class IcebergAutoMaintenance:
"""Automated Iceberg table maintenance"""
def __init__(self):
self.scheduler = BackgroundScheduler()
self.maintenance = IcebergTableMaintenance()
def start(self):
# Daily at 2 AM: expire old snapshots
self.scheduler.add_job(
self._expire_all_tables, 'cron', hour=2, minute=0
)
# Daily at 3 AM: compact small files
self.scheduler.add_job(
self._compact_all_tables, 'cron', hour=3, minute=0
)
# Weekly Sunday at 4 AM: clean orphan files
self.scheduler.add_job(
self._cleanup_orphans, 'cron', day_of_week='sun', hour=4
)
self.scheduler.start()
#7. Comparison with Palantir Foundry
| Capability | Palantir Foundry | coomia-dip (Iceberg) |
|---|---|---|
| Table format | Proprietary Dataset Format | Open Iceberg standard |
| Multi-engine support | Foundry only | Spark/Flink/Trino/Doris |
| Schema evolution | Limited | Full (add/drop/rename/type widen) |
| Partition evolution | Not supported | Online evolution |
| Time travel | Transaction-level | Snapshot-level (ms precision) |
| Data compression | Automatic | Configurable (Snappy/Zstd/LZ4) |
| Community ecosystem | Closed | Apache Top-Level Project |
#8. Common Pitfalls and Solutions
#8.1 Small File Explosion
Problem: Streaming writes (Flink/Kafka) produce many small files per minute.
Solution:
- Increase Flink checkpoint interval (from 1 min to 5 min)
- Schedule background compaction jobs
- Set
write.target-file-size-bytesappropriately
#8.2 Metadata File Bloat
Problem: Frequent snapshot operations cause metadata.json files to grow unboundedly.
Solution:
write.metadata.delete-after-commit.enabled=true
write.metadata.previous-versions-max=20
#8.3 Concurrent Write Conflicts
Problem: Multiple Flink jobs write to the same table simultaneously.
Solution:
- Isolate different jobs to different partitions
- Increase
commit.retry.num-retries - Use
write.distribution-mode=hashto distribute writes
#8.4 Query Performance Degradation Over Time
Problem: Query latency gradually increases.
Solution:
- Run
rewrite_data_filesregularly to defragment - Run
rewrite_manifeststo optimize metadata - Monitor manifest file count and small file ratio
#Key Takeaways
-
The three-layer metadata model is Iceberg's soul: By decoupling metadata from the filesystem, Iceberg achieves engine-agnostic ACID transactions, efficient query planning, and flexible schema/partition evolution. In coomia-dip, this means Doris, Spark, and Flink seamlessly share the same data without format conversion.
-
Partition evolution is the killer feature: Traditional table formats require full data rewrites and extended downtime to change partition schemes. Iceberg's partition evolution allows old and new data to coexist with different partition specs — critical for a continuously evolving platform like coomia-dip.
-
Table maintenance is mandatory, not optional: Iceberg's snapshot mechanism and small file problem demand regular maintenance (expire snapshots, compact files, rewrite manifests). Neglecting maintenance leads to query performance degradation and storage cost escalation.
#Next Article
S8-05: Kafka's 7 Usage Patterns — A deep dive into Kafka's seven core usage patterns within coomia-dip, from event sourcing to CDC transport, from stream-batch unification to cross-Layer communication.
Tags: #apache-iceberg #table-format #lakehouse #schema-evolution #partition-evolution #time-travel #coomia-dip #Layer-c