LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Apache Iceberg: Table Format for the Data Lakehouse

data-engineeringicebergsparktrinoduckdbflinks3lakehouse

Storing analytical data in Parquet files on S3 sounds simple. It works until you need to update a row. Or rename a column. Or query what the table looked like last Tuesday. Or have two jobs writing at the same time without corrupting each other. At that point you either bolt on a complex orchestration layer or you discover that your “data lake” is actually just a pile of files with no guarantees.

Apache Iceberg solves all of this. It’s an open table format — not a storage engine, not a query engine, but a specification for how to organize metadata around your existing Parquet files so they gain ACID transactions, schema evolution, time travel, and efficient query planning. Your data stays in S3 in standard Parquet format. Spark, Trino, Flink, and DuckDB all read and write it. The format is engine-neutral and vendor-neutral.

This guide covers the full picture: how Iceberg works internally, hidden partitioning, schema evolution, time travel, catalog options, Docker Compose local setup, engine-specific integrations, and the patterns you’ll use in production.


What’s Wrong with Raw Parquet on S3

A typical “data lake” setup: Parquet files in S3, partitioned by date in Hive-style directory layout.

s3://warehouse/events/
  year=2026/
    month=03/
      day=01/
        part-00000.parquet
        part-00001.parquet
      day=02/
        ...

This works for simple append-only batch jobs. It breaks the moment your requirements grow:

No updates or deletes — Parquet is immutable. Updating a record means rewriting the entire partition. GDPR deletion requests require rewriting terabytes of data.

No atomic operations — If a job fails halfway through writing, you have partial data. Readers see inconsistent state. There’s no rollback.

No schema evolution — Adding a column requires either rewriting all existing files or accepting that old files and new files have different schemas, which breaks most readers.

Hive partition pain — Queries must specify partition columns explicitly (WHERE year=2026 AND month=03). Changing the partition scheme means migrating all existing data.

No time travel — You can’t query “what did this table look like yesterday?” You’d have to have kept a separate copy.

Slow query planning — With thousands of small files, query engines must list every file in S3 to build an execution plan. S3 LIST operations are slow and expensive.

Iceberg addresses each of these problems without changing the underlying Parquet format.


How Iceberg Works Internally

Iceberg introduces a metadata layer above the Parquet files. Understanding this layer is key to understanding everything else.

┌─────────────────────────────────────────────────────────┐
│                      Catalog                             │
│  (Hive Metastore / Glue / REST / Nessie / JDBC)         │
│  Maps: table name → current metadata.json location      │
└────────────────────┬────────────────────────────────────┘
                     │ points to
┌────────────────────▼────────────────────────────────────┐
│                   metadata.json                          │
│  - Schema (columns with immutable IDs)                  │
│  - Partition spec                                        │
│  - Table location                                        │
│  - List of snapshots                                     │
│  - Current snapshot ID                                   │
└────────────────────┬────────────────────────────────────┘
                     │ current snapshot points to
┌────────────────────▼────────────────────────────────────┐
│               Manifest List (Avro)                       │
│  - One entry per manifest file                          │
│  - Partition summary stats per manifest                 │
│  - Added/existing/deleted file counts                   │
└────────────────────┬────────────────────────────────────┘
                     │ each entry points to
┌────────────────────▼────────────────────────────────────┐
│               Manifest Files (Avro)                      │
│  - List of data file paths                              │
│  - Per-file column statistics (min/max/null counts)     │
│  - Partition values for each file                       │
│  - Status: ADDED / EXISTING / DELETED                   │
└────────────────────┬────────────────────────────────────┘
                     │ each entry points to
┌────────────────────▼────────────────────────────────────┐
│               Data Files (Parquet / ORC / Avro)         │
│  - Actual rows                                          │
│  - Stored in S3 / GCS / HDFS / local filesystem        │
└─────────────────────────────────────────────────────────┘

Snapshots and atomicity

Every write operation — INSERT, UPDATE, DELETE, MERGE — creates a new snapshot. A snapshot is a complete, consistent view of the table at a point in time. Committing a snapshot is a single atomic metadata operation: the catalog updates its pointer from the old metadata.json to a new one.

If the write fails before the commit, the new data files exist in S3 but are unreachable (orphans, cleaned up during maintenance). Readers always see a consistent snapshot. There’s no partial state visible.

Why query planning is fast

When you run SELECT * FROM events WHERE ts > '2026-03-01', the query engine doesn’t list all S3 files. Instead it:

  1. Reads the current metadata.json to find the current snapshot
  2. Reads the manifest list — each entry has partition summary stats
  3. Prunes manifests whose partition ranges don’t overlap the predicate
  4. Reads only the relevant manifests to get file paths
  5. Within each file, uses Parquet column statistics for further predicate pushdown

For a table with 100,000 Parquet files partitioned by day, a query for a single day might read only 2–3 manifests instead of listing all 100,000 files.


Hidden Partitioning

Iceberg’s partitioning model is fundamentally different from Hive’s. In Hive, the partition column is a physical column in your schema — users must include it in queries for pruning to work. In Iceberg, partitioning is a transform on an existing column, invisible to query writers.

1
2
3
4
5
6
-- Hive-style: users must write WHERE year=2026 AND month=3
-- or the query scans everything
SELECT * FROM hive.events WHERE year=2026 AND month=3 AND user_id = 42;

-- Iceberg: users write natural predicates, pruning is automatic
SELECT * FROM iceberg.events WHERE ts > TIMESTAMP '2026-03-01' AND user_id = 42;

Partition transforms

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Partition by year, month, day extracted from a timestamp column
CREATE TABLE catalog.db.events (
  event_id  BIGINT,
  user_id   BIGINT,
  event_type STRING,
  ts        TIMESTAMP,
  payload   STRING
)
USING iceberg
PARTITIONED BY (year(ts), month(ts), day(ts));

-- Partition by bucket (for high-cardinality columns like user_id)
CREATE TABLE catalog.db.user_events (
  user_id   BIGINT,
  event_type STRING,
  ts        TIMESTAMP
)
USING iceberg
PARTITIONED BY (bucket(64, user_id), day(ts));

-- Partition by truncated string prefix
CREATE TABLE catalog.db.logs (
  host     STRING,
  level    STRING,
  message  STRING,
  ts       TIMESTAMP
)
USING iceberg
PARTITIONED BY (truncate(16, host), day(ts));

Available transforms:

Transform Usage Example
identity(col) Exact value identity(country)
year(ts) Year from timestamp 2026
month(ts) Year-month 2026-03
day(ts) Calendar date 2026-03-31
hour(ts) Hour 2026-03-31-14
bucket(N, col) Hash mod N bucket(64, user_id)
truncate(W, col) First W chars or rounded integer truncate(4, zip_code)

Partition evolution

You can change the partition scheme of an existing table without rewriting any data:

1
2
3
4
-- Change from daily to hourly partitioning going forward
ALTER TABLE catalog.db.events
REPLACE PARTITION FIELD day(ts)
WITH hour(ts);

Old data files keep their old partition layout. New data files use the new layout. The query planner handles both transparently. This is impossible in Hive without a full table rewrite.


Schema Evolution

Iceberg uses column IDs — immutable integers assigned at column creation — instead of column names to identify fields in data files. When you rename a column, only the schema metadata changes; existing Parquet files are untouched and still readable under the new name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- Add a new column (no data rewrite)
ALTER TABLE catalog.db.events
ADD COLUMN session_id STRING;

-- Drop a column (data retained in existing files, removed from schema)
ALTER TABLE catalog.db.events
DROP COLUMN payload;

-- Rename a column
ALTER TABLE catalog.db.events
RENAME COLUMN event_type TO event_name;

-- Reorder columns (metadata only)
ALTER TABLE catalog.db.events
ALTER COLUMN session_id FIRST;

-- Widen a numeric type (int → long, float → double)
ALTER TABLE catalog.db.events
ALTER COLUMN event_id TYPE BIGINT;

-- Add a nested column to a struct
ALTER TABLE catalog.db.events
ADD COLUMN metadata.source STRING;

Safe vs unsafe changes:

Operation Safe Notes
Add column Yes New files have it; old files return null
Drop column Yes Removed from schema; old data ignored
Rename column Yes ID unchanged; all files still readable
Reorder columns Yes Metadata only
Widen type (int→long) Yes Values still valid
Narrow type (long→int) No Would corrupt existing data
Change type incompatibly No Breaking change

Time Travel and Snapshots

Every committed write creates a snapshot. Iceberg keeps the full history until you explicitly expire old snapshots.

1
2
3
4
5
6
7
-- View snapshot history
SELECT * FROM catalog.db.events.history
ORDER BY committed_at DESC;

-- View all snapshots with summary stats
SELECT snapshot_id, committed_at, operation, added_data_files_count, deleted_data_files_count
FROM catalog.db.events.snapshots;

Time travel queries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- Query by snapshot ID
SELECT COUNT(*)
FROM catalog.db.events
FOR VERSION AS OF 8954597067493422955;

-- Query as of a specific timestamp
SELECT *
FROM catalog.db.events
FOR TIMESTAMP AS OF TIMESTAMP '2026-03-01 00:00:00 UTC';

-- Compare current vs yesterday
SELECT 'current' as version, COUNT(*) as cnt FROM catalog.db.events
UNION ALL
SELECT 'yesterday', COUNT(*) FROM catalog.db.events
FOR TIMESTAMP AS OF TIMESTAMP '2026-03-30 00:00:00 UTC';

Rollback

1
2
3
4
5
6
7
8
-- Roll back the entire table to a previous snapshot (Spark)
CALL catalog.system.rollback_to_snapshot('db.events', 8954597067493422955);

-- Roll back to a timestamp (Trino)
CALL iceberg.system.rollback_to_timestamp(
  'db', 'events',
  TIMESTAMP '2026-03-30 12:00:00 UTC'
);

Rollback is instant — it’s just a metadata update setting the current snapshot pointer back to a previous snapshot. No data is moved.


ACID Transactions

Iceberg uses optimistic concurrency control. Writers work independently and commit atomically:

  1. Read current table state (snapshot + schema)
  2. Write new data files to storage
  3. Commit by swapping metadata.json via an atomic catalog operation
  4. Conflict — if another writer committed between steps 1 and 3, the commit fails and the writer retries from step 1

The retry only regenerates the metadata tree — the data files are already written. On S3, the atomic swap uses a conditional PUT (compare-and-swap). On HDFS, it uses atomic rename.

Iceberg’s isolation levels:

  • Snapshot isolation — readers always see a consistent snapshot; never partial writes
  • Serializable (with compatible operations) — non-conflicting concurrent writes both succeed

Catalog Options

The catalog maps table names to their current metadata.json location. Iceberg supports several catalog implementations:

Catalog Best for Notes
REST New deployments, any cloud HTTP API, engine-agnostic, recommended
Hive Metastore Existing Hadoop/Hive infrastructure Broad engine support
AWS Glue AWS deployments Serverless, integrates with Athena/EMR
JDBC Simple non-cloud setups PostgreSQL/MySQL backed
Nessie Git-like data versioning Cross-table transactions, branching

Project Nessie

Nessie extends the catalog concept to support git-like operations on your entire data catalog:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Create a feature branch
nessie branch feature/new-aggregations main

# All writes on this branch are isolated from main
spark.conf.set("spark.sql.catalog.nessie.ref", "feature/new-aggregations")

# Run experiments, transformations, schema changes
spark.sql("INSERT INTO nessie.db.agg_events SELECT ...")

# Merge back to main when validated
nessie merge feature/new-aggregations --into main

# Or discard the branch (no cleanup needed — data files aren't duplicated)
nessie delete branch feature/new-aggregations

Nessie branches are metadata-only. Data files aren’t duplicated between branches. A branch that modifies a table creates a new snapshot in that branch’s catalog view while leaving the main branch untouched.


Local Development: Docker Compose

A complete local Iceberg stack with Spark, MinIO (S3-compatible storage), and the REST catalog:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# docker-compose.yml
services:
  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: password
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s

  # Creates the warehouse bucket on startup
  minio-init:
    image: minio/mc:latest
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 admin password;
      mc mb local/warehouse --ignore-existing;
      "

  # Iceberg REST catalog (backed by local filesystem for simplicity)
  iceberg-rest:
    image: apache/iceberg-rest-fixture:latest
    ports:
      - "8181:8181"
    environment:
      AWS_ACCESS_KEY_ID: admin
      AWS_SECRET_ACCESS_KEY: password
      AWS_REGION: us-east-1
      CATALOG_WAREHOUSE: s3://warehouse/
      CATALOG_IO__IMPL: org.apache.iceberg.aws.s3.S3FileIO
      CATALOG_S3_ENDPOINT: http://minio:9000
    depends_on:
      - minio-init

  # Spark with Iceberg + AWS S3 libraries
  spark:
    image: apache/spark:3.5.0-python3
    ports:
      - "4040:4040"   # Spark UI
      - "8888:8888"   # Jupyter
    environment:
      AWS_ACCESS_KEY_ID: admin
      AWS_SECRET_ACCESS_KEY: password
      AWS_REGION: us-east-1
    volumes:
      - ./notebooks:/opt/notebooks
    depends_on:
      - iceberg-rest
    command: >
      bash -c "
      pip install jupyter pyspark==3.5.0 pyiceberg &&
      jupyter notebook --ip=0.0.0.0 --port=8888 --no-browser
        --NotebookApp.token='' --notebook-dir=/opt/notebooks
      "

volumes:
  minio-data:
1
2
3
4
5
docker compose up -d

# MinIO console: http://localhost:9001 (admin/password)
# REST catalog:  http://localhost:8181
# Jupyter:       http://localhost:8888

Spark Integration

Session configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("iceberg-demo") \
    .config("spark.jars.packages",
        "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.5.2,"
        "software.amazon.awssdk:bundle:2.20.18,"
        "org.apache.hadoop:hadoop-aws:3.3.4") \
    .config("spark.sql.extensions",
        "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
    .config("spark.sql.catalog.demo", "org.apache.iceberg.spark.SparkCatalog") \
    .config("spark.sql.catalog.demo.type", "rest") \
    .config("spark.sql.catalog.demo.uri", "http://localhost:8181") \
    .config("spark.sql.catalog.demo.warehouse", "s3://warehouse/") \
    .config("spark.sql.catalog.demo.io-impl", "org.apache.iceberg.aws.s3.S3FileIO") \
    .config("spark.sql.catalog.demo.s3.endpoint", "http://localhost:9000") \
    .config("spark.hadoop.fs.s3a.endpoint", "http://localhost:9000") \
    .config("spark.hadoop.fs.s3a.access.key", "admin") \
    .config("spark.hadoop.fs.s3a.secret.key", "password") \
    .config("spark.hadoop.fs.s3a.path.style.access", "true") \
    .getOrCreate()

Creating and writing tables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pyspark.sql.types import *
from datetime import datetime

# Create namespace
spark.sql("CREATE NAMESPACE IF NOT EXISTS demo.db")

# Create table with SQL
spark.sql("""
    CREATE TABLE IF NOT EXISTS demo.db.events (
        event_id  BIGINT,
        user_id   BIGINT,
        event_type STRING,
        ts        TIMESTAMP,
        props     MAP<STRING, STRING>
    )
    USING iceberg
    PARTITIONED BY (day(ts))
    TBLPROPERTIES (
        'format-version' = '2',
        'write.target-file-size-bytes' = '134217728'
    )
""")

# Insert via DataFrame API
from pyspark.sql import Row

rows = [
    Row(event_id=1, user_id=101, event_type="page_view",
        ts=datetime(2026, 3, 31, 10, 0, 0), props={"page": "/home"}),
    Row(event_id=2, user_id=102, event_type="purchase",
        ts=datetime(2026, 3, 31, 11, 30, 0), props={"amount": "99.99"}),
]

df = spark.createDataFrame(rows)
df.writeTo("demo.db.events").append()

# Or: overwrite a specific partition
df.writeTo("demo.db.events") \
    .overwritePartitions()

MERGE INTO (upserts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Merge updates into the target table
MERGE INTO demo.db.users t
USING (
    SELECT 101 AS user_id, 'Alice Updated' AS name, 'alice@example.com' AS email
) s
ON t.user_id = s.user_id
WHEN MATCHED THEN
    UPDATE SET t.name = s.name, t.email = s.email
WHEN NOT MATCHED THEN
    INSERT (user_id, name, email) VALUES (s.user_id, s.name, s.email);

Reading metadata tables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Files in the current snapshot
spark.sql("SELECT * FROM demo.db.events.files").show()

# Full snapshot history
spark.sql("SELECT * FROM demo.db.events.history").show()

# All snapshots with summary stats
spark.sql("SELECT * FROM demo.db.events.snapshots").show()

# Current partitions and row counts
spark.sql("SELECT * FROM demo.db.events.partitions").show()

# Manifest files
spark.sql("SELECT * FROM demo.db.events.manifests").show()

Trino Integration

Trino’s Iceberg connector is mature and production-ready.

1
2
3
4
5
6
7
8
9
# etc/catalog/iceberg.properties
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=http://iceberg-rest:8181
iceberg.file-format=PARQUET
hive.s3.endpoint=http://minio:9000
hive.s3.aws-access-key=admin
hive.s3.aws-secret-key=password
hive.s3.path-style-access=true
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- Create table
CREATE TABLE iceberg.db.orders (
    order_id   BIGINT,
    customer   VARCHAR,
    total      DOUBLE,
    ordered_at TIMESTAMP WITH TIME ZONE
)
WITH (
    format = 'PARQUET',
    partitioning = ARRAY['day(ordered_at)', 'bucket(customer, 16)'],
    format_version = '2'
);

-- Insert
INSERT INTO iceberg.db.orders VALUES
    (1, 'alice', 49.99, TIMESTAMP '2026-03-31 10:00:00 UTC'),
    (2, 'bob',   129.99, TIMESTAMP '2026-03-31 11:00:00 UTC');

-- Time travel
SELECT * FROM iceberg.db.orders
FOR TIMESTAMP AS OF TIMESTAMP '2026-03-31 10:30:00 UTC';

-- Query metadata
SELECT * FROM iceberg.db.orders$snapshots;
SELECT * FROM iceberg.db.orders$history;
SELECT * FROM iceberg.db.orders$files;

-- Rollback
CALL iceberg.system.rollback_to_snapshot('db', 'orders', 1234567890);

-- Table maintenance
ALTER TABLE iceberg.db.orders EXECUTE expire_snapshots(retention_period => INTERVAL '7' DAY);
ALTER TABLE iceberg.db.orders EXECUTE remove_orphan_files();
ALTER TABLE iceberg.db.orders EXECUTE optimize;

DuckDB: Local Iceberg Queries

DuckDB can read Iceberg tables from local disk or S3 without a cluster.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import duckdb

con = duckdb.connect()
con.install_extension("iceberg")
con.load_extension("iceberg")

# Read a local Iceberg table (useful for testing)
result = con.sql("""
    SELECT event_type, COUNT(*) as cnt
    FROM iceberg_scan('/tmp/warehouse/db/events', allow_moved_paths=true)
    GROUP BY event_type
    ORDER BY cnt DESC
""").fetchdf()
print(result)

# Read from S3
con.execute("""
    SET s3_endpoint='localhost:9000';
    SET s3_access_key_id='admin';
    SET s3_secret_access_key='password';
    SET s3_url_style='path';
    SET s3_use_ssl=false;
""")

result = con.sql("""
    SELECT * FROM iceberg_scan('s3://warehouse/db/events')
    WHERE ts > TIMESTAMP '2026-03-01'
    LIMIT 100
""").fetchdf()

DuckDB’s Iceberg support is ideal for:

  • Local development and testing without a cluster
  • Ad-hoc queries on production Iceberg tables from a laptop
  • Quickly validating table contents after ingestion jobs

Flink is the preferred engine for streaming ingestion into Iceberg.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- Flink SQL: create Iceberg catalog
CREATE CATALOG iceberg_catalog WITH (
  'type'            = 'iceberg',
  'catalog-type'    = 'rest',
  'uri'             = 'http://iceberg-rest:8181',
  'warehouse'       = 's3://warehouse',
  's3.endpoint'     = 'http://minio:9000',
  's3.access-key'   = 'admin',
  's3.secret-key'   = 'password',
  's3.path-style-access' = 'true'
);

-- Create target Iceberg table (format-version 2 required for upserts)
CREATE TABLE iceberg_catalog.db.user_events (
  user_id    BIGINT,
  event_type STRING,
  ts         TIMESTAMP(3),
  PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
  'format-version' = '2',
  'write.upsert.enabled' = 'true'
);

-- Create Kafka source
CREATE TABLE kafka_events (
  user_id    BIGINT,
  event_type STRING,
  ts         TIMESTAMP(3),
  WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH (
  'connector' = 'kafka',
  'topic'     = 'user-events',
  'properties.bootstrap.servers' = 'kafka:9092',
  'format'    = 'json'
);

-- Stream from Kafka into Iceberg with upsert
INSERT INTO iceberg_catalog.db.user_events
SELECT user_id, event_type, ts FROM kafka_events;

For upsert mode, Iceberg uses equality delete files — small files recording which rows were deleted/replaced — rather than rewriting entire data files. Periodic compaction merges these into the base files.


Table Maintenance

Without maintenance, Iceberg tables accumulate small files (from streaming writes and incremental jobs), stale snapshots, and orphaned data files. Run these regularly.

Compaction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- Spark: rewrite small files into target size (128 MB default)
CALL demo.system.rewrite_data_files(
  table => 'db.events',
  strategy => 'binpack',
  options => map('target-file-size-bytes', '134217728',
                 'min-file-size-bytes', '67108864')
);

-- Rewrite only specific partitions
CALL demo.system.rewrite_data_files(
  table => 'db.events',
  where => 'ts >= TIMESTAMP ''2026-03-01'' AND ts < TIMESTAMP ''2026-04-01'''
);

-- Trino
ALTER TABLE iceberg.db.events EXECUTE optimize
WHERE ts >= DATE '2026-03-01';

Expire snapshots

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Spark: remove snapshots older than 7 days, keep at least 2
CALL demo.system.expire_snapshots(
  table => 'db.events',
  older_than => TIMESTAMP '2026-03-24 00:00:00',
  retain_last => 2
);

-- Trino
ALTER TABLE iceberg.db.events
EXECUTE expire_snapshots(retention_period => INTERVAL '7' DAY);

Remove orphan files

1
2
3
4
5
6
7
8
-- Spark: files in storage not referenced by any snapshot
CALL demo.system.remove_orphan_files(
  table => 'db.events',
  older_than => TIMESTAMP '2026-03-24 00:00:00'
);

-- Trino
ALTER TABLE iceberg.db.events EXECUTE remove_orphan_files();

Maintenance schedule

A reasonable schedule for a medium-traffic table:

1
2
3
4
# Airflow DAG or cron
# Daily: expire old snapshots + remove orphans
# Weekly: full compaction of last 7 days
# Monthly: compaction of older partitions (if write patterns were heavy)

Real-World Patterns

CDC ingestion with MERGE INTO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
-- Source: Debezium CDC events from Kafka, landed in a staging table
-- Target: Production users table

MERGE INTO demo.db.users t
USING (
    -- Deduplicate: take latest operation per user_id
    SELECT user_id, name, email, address, op
    FROM (
        SELECT *,
               ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) as rn
        FROM demo.db.users_cdc_staging
    ) WHERE rn = 1
) s
ON t.user_id = s.user_id
WHEN MATCHED AND s.op = 'DELETE' THEN DELETE
WHEN MATCHED THEN
    UPDATE SET t.name = s.name, t.email = s.email, t.address = s.address
WHEN NOT MATCHED AND s.op != 'DELETE' THEN
    INSERT (user_id, name, email, address)
    VALUES (s.user_id, s.name, s.email, s.address);

Incremental processing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Read only new data since last run
last_snapshot = get_last_processed_snapshot()  # From your state store
current_snapshot = spark.sql(
    "SELECT snapshot_id FROM demo.db.events.snapshots ORDER BY committed_at DESC LIMIT 1"
).collect()[0][0]

incremental_df = spark.read \
    .option("start-snapshot-id", last_snapshot) \
    .option("end-snapshot-id", current_snapshot) \
    .table("demo.db.events")

# Process and save results
results = incremental_df.groupBy("event_type").count()
results.writeTo("demo.db.event_counts").overwritePartitions()

save_last_processed_snapshot(current_snapshot)

Data validation with time travel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# After a major migration, validate row counts match
before_count = spark.sql("""
    SELECT COUNT(*) as cnt
    FROM demo.db.events
    FOR TIMESTAMP AS OF TIMESTAMP '2026-03-30 23:59:59'
""").collect()[0]["cnt"]

after_count = spark.sql("SELECT COUNT(*) as cnt FROM demo.db.events").collect()[0]["cnt"]

assert after_count >= before_count, \
    f"Row count decreased: {before_count}{after_count}"

# Find rows present before but missing after (should be 0 for append-only tables)
missing = spark.sql("""
    SELECT event_id FROM demo.db.events
    FOR TIMESTAMP AS OF TIMESTAMP '2026-03-30 23:59:59'
    EXCEPT
    SELECT event_id FROM demo.db.events
""")
assert missing.count() == 0, "Rows were deleted during migration"

Iceberg vs Delta Lake vs Hudi

Feature Apache Iceberg Delta Lake Apache Hudi
Origin Netflix Databricks Uber
License Apache 2.0 Apache 2.0 Apache 2.0
Hidden partitioning Yes No No
Partition evolution Yes (metadata only) No Complex
Schema evolution Full Partial Limited
ACID transactions Yes Yes Yes
Time travel Full snapshot history Version-based Partial
Multi-engine support Excellent (Spark, Trino, Flink, DuckDB, Presto) Good (Spark-first) Moderate
Streaming upserts Via Flink Via Spark Native (designed for it)
Catalog spec Open REST spec Delta-specific Hudi-specific
Cross-table transactions Via Nessie No No
Best for Analytics, multi-engine, complex schemas Databricks ecosystem High-frequency CDC, record-level updates

When to choose Iceberg: You’re not locked into Databricks, you need multiple engines to read/write the same tables, you have complex schema evolution requirements, or you want to use Trino/DuckDB alongside Spark.

When to choose Delta Lake: Your team is already in the Databricks ecosystem and won’t be leaving.

When to choose Hudi: Your primary use case is streaming high-frequency record-level upserts (think ride-sharing event streams where millions of records per second are being updated).


Summary

Apache Iceberg transforms object storage from a pile of files into a proper database table format:

Capability How Iceberg delivers it
ACID transactions Optimistic concurrency + atomic metadata swap
No more S3 LIST bottleneck Manifest files with column statistics
Schema evolution without rewrites Immutable column IDs
Hidden partitioning Partition transforms on existing columns
Time travel Immutable snapshot chain
Multi-engine Open spec + REST catalog
Git for data Project Nessie

The practical path into Iceberg: start with the Docker Compose stack for local development, prove out your use case with Spark SQL, then connect Trino for ad-hoc analytics and DuckDB for local iteration. Add Flink when you need streaming ingestion. Run compaction and snapshot expiration as daily jobs from the start.

The data lake era — raw files with no guarantees — is ending. Iceberg, along with Delta Lake and Hudi, is what the lakehouse is actually built on.

Comments