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:
- Reads the current
metadata.json to find the current snapshot
- Reads the manifest list — each entry has partition summary stats
- Prunes manifests whose partition ranges don’t overlap the predicate
- Reads only the relevant manifests to get file paths
- 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;
|
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:
- Read current table state (snapshot + schema)
- Write new data files to storage
- Commit by swapping
metadata.json via an atomic catalog operation
- 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);
|
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: Streaming Writes
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