Back to Blog

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:

Coomia DIPPublished on November 12, 202514 min read
Share this articleTwitter / X
“

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:

DimensionHive TableApache Iceberg
MetadataFilesystem directoriesIndependent metadata files
Partition discoveryDirectory listingMetadata index
TransactionsNoneACID (optimistic concurrency)
Schema evolutionAppend-only columnsFull evolution (add/drop/rename/reorder)
Partition evolutionNot supportedOnline evolution
Time travelNot supportedSnapshot-based
Engine bindingTightly coupledEngine-agnostic

#1.3 Iceberg's Position in coomia-dip

Code
┌─────────────────────────────────────────────────┐
│                  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:

JSON
{
  "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

Code
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:

Code
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:

  1. Partition pruning: Skip entire partitions based on partition values in the manifest list
  2. File pruning: Skip data files based on column min/max bounds in manifests
  3. 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

Python
# 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:

Python
# 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

Python
# 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

Python
# 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()
Java
// 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

SQL
-- 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

StrategyDescriptionUse Case
Target file sizeSet write.target-file-size-bytes=134217728 (128MB)General
Fanout writewrite.distribution-mode=hashHigh-cardinality partitions
Sorted writewrite.distribution-mode=rangeRange query optimization
Small file mergeSchedule regular compaction jobsAfter streaming ingestion

#4.2 Read Optimization

Python
# 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

Python
# 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

ParameterDefaultRecommendedNotes
write.target-file-size-bytes512MB128MBSmaller files improve incremental queries
write.metadata.delete-after-commit.enabledfalsetrueAuto-clean old metadata files
write.metadata.previous-versions-max10020Limit metadata history versions
read.split.target-size128MB256MBRead split size for query parallelism
commit.retry.num-retries410Retry count for concurrent write conflicts
commit.retry.min-wait-ms100200Minimum retry wait time

#5. Migration from Hive Tables

#5.1 In-Place Migration

SQL
-- 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:

Python
# 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

MetricAlert ThresholdDescription
Snapshot count> 1000Run expire_snapshots
Manifest file count> 500Run rewrite_manifests
Small file ratio (< 10MB)> 30%Run compaction
Metadata file size> 10MBClean history versions
Orphan file count> 0Run remove_orphan_files

#6.2 Automated Maintenance Schedule

Python
# 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

CapabilityPalantir Foundrycoomia-dip (Iceberg)
Table formatProprietary Dataset FormatOpen Iceberg standard
Multi-engine supportFoundry onlySpark/Flink/Trino/Doris
Schema evolutionLimitedFull (add/drop/rename/type widen)
Partition evolutionNot supportedOnline evolution
Time travelTransaction-levelSnapshot-level (ms precision)
Data compressionAutomaticConfigurable (Snappy/Zstd/LZ4)
Community ecosystemClosedApache 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-bytes appropriately

#8.2 Metadata File Bloat

Problem: Frequent snapshot operations cause metadata.json files to grow unboundedly.

Solution:

PROPERTIES
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=hash to distribute writes

#8.4 Query Performance Degradation Over Time

Problem: Query latency gradually increases.

Solution:

  • Run rewrite_data_files regularly to defragment
  • Run rewrite_manifests to optimize metadata
  • Monitor manifest file count and small file ratio

#Key Takeaways

  1. 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.

  2. 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.

  3. 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