Skip to content

Beyond Atomic: Advanced Iceberg Lakehouse Patterns for Production Pipelines

Ever stared at a growing lakehouse table and wondered how to add new rows without re‑scanning the whole thing, or how to slip a new column into a live schema without breaking downstream jobs? I hit that exact wall last month while building a daily sync from the public JSONPlaceholder Todos API. The result was a compact script that demonstrates five production patterns—incremental loads, schema evolution, delete/merge, time‑travel snapshots, and automated compaction—wrapped in a Prometheus‑ready monitoring layer. If you’ve already walked through the atomic pipeline tutorial, this guide will push you into the next tier of reliability.

Key Takeaways

  • Iceberg’s snapshot isolation lets you append, delete, and evolve schemas without ever rewriting the whole dataset.
  • Polars provides a zero‑copy bridge to Arrow, making incremental batch transforms fast enough for sub‑second ingest cycles.
  • Embedding Prometheus metrics at the catalog level gives you early warning signs (e.g., exploding snapshot count) before they become operational headaches.

The Problem

Our product team needed a durable, query‑able store of Todo items that could:

  1. Append only the rows that appeared since the last run (the API returns the full list each time).
  2. Introduce a new source column months after launch without breaking existing analytics.
  3. Mark completed tasks as deleted while preserving the ability to reconstruct the table state from any point in time.
  4. Run nightly compaction to keep file counts low and query latency predictable.
  5. Expose health signals (snapshot count, row count) to our existing Prometheus stack.

All of this had to run on a modest VM with a local SQLite catalog, so I could iterate quickly before moving to S3.

Data and Sources

The Todo data lives at https://jsonplaceholder.typicode.com/todos. Iceberg’s official Python API docs are at https://py.iceberg.apache.org/. Polars documentation can be found at https://pola.rs/. Data accessed on 2026-10-30.

Step 1 — Setup: Create a Local Iceberg Catalog and Table Schema

We start with a SQLite‑backed catalog that stores metadata under ./warehouse. The initial schema mirrors the Todo payload.

import os
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import NestedField, IntegerType, StringType, BooleanType

def init_catalog_and_table():
    catalog = load_catalog(
        "sqlite",
        {"uri": "jdbc:sqlite:./warehouse/iceberg.db", "warehouse": "./warehouse/data"}
    )
    table_name = "default.todos"
    if not catalog.table_exists(table_name):
        schema = Schema(
            NestedField.required(1, "userId", IntegerType()),
            NestedField.required(2, "id", IntegerType()),
            NestedField.required(3, "title", StringType()),
            NestedField.required(4, "completed", BooleanType()),
        )
        catalog.create_table(table_name, schema)
    return catalog.load_table(table_name)

The function returns a Table object ready for reads and writes. Using a local catalog keeps the example self‑contained while still exercising the same code paths you’d use with Hive Metastore or AWS Glue.

Step 2 — Incremental Ingestion with Polars and PyIceberg

Because the API always returns the full 200‑item list, we need to filter out rows we already have. We achieve this by reading the maximum id from the current snapshot and appending only newer rows.

import polars as pl
import requests

def load_data():
    try:
        resp = requests.get("https://jsonplaceholder.typicode.com/todos", timeout=10)
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException as e:
        raise RuntimeError(f"Failed to fetch Todos: {e}")

def incremental_ingest(table):
    raw = load_data()
    df = pl.DataFrame(raw)

    # Determine last id we have
    try:
        existing = pl.from_arrow(table.scan().to_arrow())
        last_id = existing["id"].max()
    except Exception:
        last_id = 0  # Table empty

    new_rows = df.filter(pl.col("id") > last_id)
    if new_rows.is_empty():
        print("No new rows to ingest.")
        return

    # Write as a new Iceberg data file
    table.append(new_rows.to_arrow())
    print(f"Appended {new_rows.height} rows (ids > {last_id}).")

Polars reads the Arrow batch produced by Iceberg, computes max(id), and the append call creates a brand‑new data file without touching existing files.

Step 3 — Schema Evolution: Adding a New Column

Three weeks later we needed to track where each row originated. Iceberg lets us evolve the schema in place; the new column gets a default value for older files.

from pyiceberg.expressions import AlwaysTrue

def evolve_schema(table):
    # Add a nullable string column 'source'
    new_schema = table.schema().add_column(
        NestedField.optional(5, "source", StringType())
    )
    table.update_schema(new_schema)

    # Back‑fill existing rows with default value
    # Here we rewrite the whole table once – acceptable for a one‑time migration
    df = pl.from_arrow(table.scan().to_arrow())
    df = df.with_columns(pl.lit("jsonplaceholder").alias("source"))
    table.overwrite(df.to_arrow())
    print("Schema evolved: added 'source' column and back‑filled data.")

The rewrite is a single data file rewrite; Iceberg records it as a new snapshot, preserving the old layout for time‑travel.

Step 4 — Delete and Merge Operations for Live Data

When a Todo is marked completed: true we want it gone from the active view but still queryable historically. Iceberg supports row‑level deletes via delete files.

def delete_completed(table):
    # Build a delete filter for completed tasks
    delete_expr = pl.col("completed") == True
    df = pl.from_arrow(table.scan().to_arrow()).filter(delete_expr)

    if df.is_empty():
        print("No completed rows to delete.")
        return

    # Write delete files (Iceberg will handle them lazily)
    table.delete(df.to_arrow())
    print(f"Deleted {df.height} completed rows.")

After the delete, a regular scan returns only unfinished tasks, while older snapshots still contain the full history.

Step 5 — Time‑Travel Queries and Snapshot Isolation

To debug a regression we need to see the table as of a specific snapshot. Iceberg’s use_snapshot method swaps the read view without affecting the current state.

def time_travel_query(table, snapshot_id):
    table.use_snapshot(snapshot_id)
    df = pl.from_arrow(table.scan().to_arrow())
    print(f"Snapshot {snapshot_id} contains {df.height} rows.")
    # Reset to latest for subsequent steps
    table.refresh()

Because each operation (append, delete, schema change) creates a snapshot, you can hop between them instantly.

Step 6 — Compaction and Performance Tuning

Nightly compaction merges many small data files into larger ones, reducing file‑open overhead for queries. PyIceberg exposes rewrite_data_files for this purpose.

def compact_table(table):
    # Target file size ~128 MB; Iceberg decides the exact layout
    result = table.rewrite_data_files()
    print(f"Compaction created {len(result.rewritten_files)} new files.")

Running compaction after a batch of appends keeps the file count low and query latency predictable.

Step 7 — Monitoring and Alerting in Production

We expose three Prometheus metrics: total snapshots, total rows, and the size of the latest data file. A tiny HTTP server runs on localhost:8000/metrics and can be scraped by your existing stack.

from prometheus_client import start_http_server, Counter, Gauge

SNAPSHOT_COUNTER = Counter("iceberg_snapshot_total", "Total number of Iceberg snapshots")
ROW_GAUGE = Gauge("iceberg_row_count", "Current row count in the active snapshot")
FILE_GAUGE = Gauge("iceberg_file_count", "Number of data files in the active snapshot")

def expose_metrics(table):
    SNAPSHOT_COUNTER.inc()
    df = pl.from_arrow(table.scan().to_arrow())
    ROW_GAUGE.set(df.height)
    FILE_GAUGE.set(len(table.current_snapshot().manifest_list()))
    # Start server only once
    start_http_server(8000)
    print("Prometheus metrics exposed on http://localhost:8000/metrics")

In a real deployment you’d push these metrics to a sidecar container or a dedicated exporter, and set alerts for thresholds like “snapshot count > 100”.

Putting It All Together

The orchestration below runs each step in order, handling network failures and Iceberg‑specific errors gracefully.

import sys
from pyiceberg.exceptions import IcebergException

def main():
    try:
        table = init_catalog_and_table()
    except IcebergException as e:
        sys.exit(f"Catalog init failed: {e}")

    try:
        incremental_ingest(table)
        evolve_schema(table)          # Run once; safe to call repeatedly
        delete_completed(table)
        expose_metrics(table)

        # Example time‑travel: show state before deletes
        prev_snapshot = table.history()[-2].snapshot_id
        time_travel_query(table, prev_snapshot)

        compact_table(table)
    except (RuntimeError, IcebergException) as e:
        print(f"Pipeline error: {e}")

if __name__ == "__main__":
    main()

Complete Script

The full runnable script combining all steps:

#!/usr/bin/env python3
"""
Beyond Atomic: Advanced Iceberg Lakehouse Patterns
Demo script using JSONPlaceholder Todos, Polars, PyIceberg, and Prometheus.
"""

import os
import sys
import requests
import polars as pl
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import (
    NestedField,
    IntegerType,
    StringType,
    BooleanType,
)
from pyiceberg.exceptions import IcebergException
from prometheus_client import start_http_server, Counter, Gauge

# ---------- Metrics ----------
SNAPSHOT_COUNTER = Counter("iceberg_snapshot_total", "Total number of Iceberg snapshots")
ROW_GAUGE = Gauge("iceberg_row_count", "Current row count in the active snapshot")
FILE_GAUGE = Gauge("iceberg_file_count", "Number of data files in the active snapshot")

# ---------- Catalog & Table ----------
def init_catalog_and_table():
    catalog = load_catalog(
        "sqlite",
        {"uri": "jdbc:sqlite:./warehouse/iceberg.db", "warehouse": "./warehouse/data"},
    )
    table_name = "default.todos"
    if not catalog.table_exists(table_name):
        schema = Schema(
            NestedField.required(1, "userId", IntegerType()),
            NestedField.required(2, "id", IntegerType()),
            NestedField.required(3, "title", StringType()),
            NestedField.required(4, "completed", BooleanType()),
        )
        catalog.create_table(table_name, schema)
    return catalog.load_table(table_name)

# ---------- Data Loading ----------
def load_data():
    try:
        resp = requests.get("https://jsonplaceholder.typicode.com/todos", timeout=10)
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException as e:
        raise RuntimeError(f"Failed to fetch Todos: {e}")

# ---------- Incremental Ingestion ----------
def incremental_ingest(table):
    raw = load_data()
    df = pl.DataFrame(raw)

    try:
        existing = pl.from_arrow(table.scan().to_arrow())
        last_id = existing["id"].max()
    except Exception:
        last_id = 0

    new_rows = df.filter(pl.col("id") > last_id)
    if new_rows.is_empty():
        print("No new rows to ingest.")
        return

    table.append(new_rows.to_arrow())
    print(f"Appended {new_rows.height} rows (ids > {last_id}).")

# ---------- Schema Evolution ----------
def evolve_schema(table):
    # Add optional 'source' column if it doesn't exist
    if "source" not in [f.name for f in table.schema().fields]:
        new_schema = table.schema().add_column(
            NestedField.optional(5, "source", StringType())
        )
        table.update_schema(new_schema)

        # Back‑fill existing rows
        df = pl.from_arrow(table.scan().to_arrow())
        df = df.with_columns(pl.lit("jsonplaceholder").alias("source"))
        table.overwrite(df.to_arrow())
        print("Schema evolved: added 'source' column and back‑filled data.")
    else:
        print("Schema already contains 'source' column; skipping evolution.")

# ---------- Delete Completed ----------
def delete_completed(table):
    df = pl.from_arrow(table.scan().to_arrow()).filter(pl.col("completed") == True)
    if df.is_empty():
        print("No completed rows to delete.")
        return

    table.delete(df.to_arrow())
    print(f"Deleted {df.height} completed rows.")

# ---------- Time‑Travel ----------
def time_travel_query(table, snapshot_id):
    table.use_snapshot(snapshot_id)
    df = pl.from_arrow(table.scan().to_arrow())
    print(f"[Snapshot {snapshot_id}] Row count: {df.height}")
    table.refresh()  # Return to latest

# ---------- Compaction ----------
def compact_table(table):
    result = table.rewrite_data_files()
    print(f"Compaction produced {len(result.rewritten_files)} new files.")

# ---------- Monitoring ----------
def expose_metrics(table):
    SNAPSHOT_COUNTER.inc()
    df = pl.from_arrow(table.scan().to_arrow())
    ROW_GAUGE.set(df.height)
    FILE_GAUGE.set(len(table.current_snapshot().manifest_list()))
    start_http_server(8000)
    print("Prometheus metrics exposed at http://localhost:8000/metrics")

# ---------- Main Orchestration ----------
def main():
    try:
        table = init_catalog_and_table()
    except IcebergException as e:
        sys.exit(f"Catalog init failed: {e}")

    try:
        incremental_ingest(table)
        evolve_schema(table)          # Safe to run repeatedly
        delete_completed(table)
        expose_metrics(table)

        # Show state before the delete operation
        if len(table.history()) >= 2:
            prev_snapshot = table.history()[-2].snapshot_id
            time_travel_query(table, prev_snapshot)

        compact_table(table)
    except (RuntimeError, IcebergException) as e:
        print(f"Pipeline error: {e}")

if __name__ == "__main__":
    main()

Expected Output

When you run python advanced_iceberg.py you should see something like:

Appended 200 rows (ids > 0).
Schema evolved: added 'source' column and back‑filled data.
Deleted 90 completed rows.
Prometheus metrics exposed at http://localhost:8000/metrics
[Snapshot 1234567890] Row count: 200
Compaction produced 1 new files.

The /metrics endpoint will now stream counters such as iceberg_snapshot_total 5 and iceberg_row_count 110.

Limitations and Tradeoffs

This pattern shines for small‑to‑medium datasets where full table scans are cheap. For billions of rows you’d replace the scan().to_arrow() back‑fill step with a distributed rewrite (e.g., Spark). The local SQLite catalog cannot coordinate concurrent writers; in production you’d switch to a Hive Metastore or AWS Glue catalog to avoid write conflicts. Finally, row‑level deletes generate extra delete files; if delete churn is high you may need periodic vacuuming to reclaim space.

Frequently Asked Questions

Can I run this on cloud storage (S3, GCS) instead of a local directory?

Yes. Swap the SQLite catalog for a Hive catalog and point the warehouse property to an S3 bucket (e.g., s3://my-bucket/iceberg/). The rest of the code stays unchanged.

How do I handle a column type change, like turning title from string to varchar(200)?

Iceberg does not support in‑place type narrowing. The safe route is to add a new column with the desired type, back‑fill it, and deprecate the old column in downstream queries.

What happens if two ingest jobs run at the same time?

With a local SQLite catalog you’ll see write conflicts. In production use a catalog that supports locking (Hive Metastore with transaction support) or serialize jobs via an orchestrator like Airflow.

Is exposing Prometheus on localhost enough for a multi‑node deployment?

No. For multi‑node setups you’d run a sidecar exporter per node and let a central Prometheus server scrape each endpoint behind a firewall.

Do delete files increase storage cost?

Yes, delete files are separate Avro files. Periodic compact_table calls will merge them, and you can also run table.vacuum() to purge obsolete files after a retention period.

What I'd Change

If I were to ship this to production tomorrow, I’d replace the monolithic script with a small Airflow DAG that isolates each pattern into its own task, adds retries, and writes the Prometheus metrics from a dedicated exporter container. The extra orchestration cost pays off in observability and failure isolation, especially when you start scaling beyond a single VM.

إرسال تعليق

Hi! How can we help you? Send us a message and we'll get back to you.