Iceberg format versions: what v1, v2, v3 and v4 actually change
The format version decides whether a table can delete a row without rewriting a file, and whether every row carries an identity. All four versions were created and written against the same engine. Here is what each adds and what upgrading costs.
- What a format version is
- v1: the table is a list of files
- v2: delete files
- v3: deletion vectors and row lineage
- Telling a table’s version apart from the outside
- What actually changes for a reader
- An upgrade that is safe to run
- Where v3 pays off, measured in file counts
- v4
- Upgrading
- Which version to use
- Common misconceptions
- Run it yourself
- A model worth keeping
- References
- Trademarks
TL;DR
- Creating tables at format-version 1, 2, 3 and 4 all succeeded on the same engine, and all accepted writes and deletes. Version 5 was refused.
- v2 is the default for new tables and the version that introduced delete files, which is what merge-on-read needs.
- v3 and v4 metadata carry
next-row-id; v1 and v2 do not. That field is the row-lineage machinery.- The version only moves forward, and it is a table property, so a table upgraded past what a reader supports becomes unreadable to that reader.
- A
DELETEon a v1 table still works — by rewriting files. Version does not decide whether you can delete, only how.- On Spark, a v2 merge-on-read table already keeps one position-delete file per data file. What v3 changes is the form: a deletion vector of under 0.5 KB instead of a 1.7 KB Parquet file, whatever the writer’s settings.
What a format version is
It is the version of the on-disk specification a table is written to: which metadata fields exist, which file types are legal, what a reader must understand.
It is recorded as a table property, so it is per table rather than per cluster:
CREATE TABLE ice.blog_format_versions.orders_v2
(order_id BIGINT, region STRING, amount DECIMAL(10,2)) USING iceberg
TBLPROPERTIES ('format-version' = '2');
Every version was exercised against the same engine by
format_versions.py, three orders per table:
format-version=1: CREATE ok, rows=3, metadata format-version=1, next-row-id=absent
format-version=2: CREATE ok, rows=3, metadata format-version=2, next-row-id=absent
format-version=3: CREATE ok, rows=3, metadata format-version=3, next-row-id=3
format-version=4: CREATE ok, rows=3, metadata format-version=4, next-row-id=3
format-version=5: CREATE failed: IllegalArgumentException: Unsupported format version: v5 (supported: v4)
All four create, write and delete. The difference is what the metadata contains
and what the engine is permitted to do. The ceiling is in the library, not the
spec: Iceberg 1.11.0 defines SUPPORTED_TABLE_FORMAT_VERSION = 4 and
DEFAULT_TABLE_FORMAT_VERSION = 2 in TableMetadata.
v1: the table is a list of files
v1 established the idea the whole format rests on: a table is a set of data files named in metadata, committed atomically, with schema and partitioning tracked independently of directory layout.
That gives atomic commits, time travel, schema evolution by field id, hidden partitioning and partition evolution. Everything in the introduction to Iceberg is v1.
What it lacks is any way to express “this row is gone” other than rewriting the
file that holds it. A DELETE against a v1 table still works:
format-version=1 after DELETE: rows=2, data=2 position/DV deletes=0 equality deletes=0, operation=overwrite
It rewrote: the snapshot is an overwrite, and there are no delete files. For a
table updated occasionally that is fine and is exactly what copy-on-write does on
any version. The same DELETE on the v2, v3 and v4 tables printed the same line,
because copy-on-write is the default everywhere.
Asking a v1 table for merge-on-read does not fail. With
'write.delete.mode' = 'merge-on-read' set, the next DELETE still rewrote the
file and wrote no delete file:
v1 merge-on-read DELETE: rows=1, data=1 position/DV deletes=0 equality deletes=0
v2: delete files
v2 added delete files — separate files that mark rows as removed without touching the data file. That single addition is what makes merge-on-read possible, and therefore what makes CDC and streaming upserts practical.
Two kinds exist. Position deletes name a data file and row positions within it. Equality deletes are predicates on column values, which lets a writer delete without first finding where the row lives — the reason Flink CDC pipelines produce them.
v2 is the default for new tables, which is why a table created without specifying anything reports:
format-version = 2
write.parquet.compression-codec = zstd
The cost is that readers must apply delete files. An engine that ignores them returns deleted rows, which is worse than failing. The trade, measured on identical tables, is in copy-on-write or merge-on-read.
v3: deletion vectors and row lineage
v3 changes how deletes are represented and adds identity to rows.
Deletion vectors replace position delete files with a bitmap per data file, stored in a Puffin file. A bitmap is more compact than a list of positions, and the spec allows at most one per data file: a new delete merges the old bitmap into a new one rather than adding a file beside it. On v2 that property depends on the writer, as the file counts below show.
Row lineage gives each row an identity that survives rewrites, so a row can
be followed across compactions. Two metadata columns expose it, _row_id and
_last_updated_sequence_number:
v2: [(1001, None, None), (1003, None, None)]
v3: [(1001, 0, 1), (1003, 2, 1)]
v3 after UPDATE: [(1001, 0, 3), (1003, 2, 1)]
v3 next-row-id now: 6
On v2 both columns are NULL. On v3 order 1001 kept _row_id 0 through a
copy-on-write UPDATE that rewrote its file; only its sequence number moved, to
the commit that changed it. Order 1002, id 1, had been deleted. next-row-id is
the counter that assigns the identities: it appears in v3 and v4 metadata and in
neither v1 nor v2, which is the cleanest way to tell which generation a table
belongs to.
v3 also defines default values for columns, so a column added with a default would not need existing files rewritten to materialise it. Spark cannot use it yet. On Spark 3.5.9 with Iceberg 1.11.0 the statement fails on v3 exactly as on v2:
v3 ADD COLUMN ... DEFAULT: UnsupportedOperationException: Cannot add column channel since setting default values in Spark is currently unsupported
Telling a table’s version apart from the outside
Three ways, in increasing order of effort.
SHOW TBLPROPERTIES ice.blog_format_versions.orders_v3 ('format-version');
The second reads the current metadata.json itself. The table’s
metadata_log_entries names it, so this works on object storage without knowing
the table’s layout:
import json
path = spark.sql("SELECT file FROM ice.blog_format_versions.orders_v3.metadata_log_entries "
"ORDER BY timestamp DESC LIMIT 1").collect()[0][0]
md = json.loads(spark.read.text(path, wholetext=True).collect()[0][0])
print("format-version:", md["format-version"], "next-row-id:", md.get("next-row-id", "absent"))
It distinguishes v3 from v2 without trusting a property: next-row-id appears in
v3 and v4 metadata and in neither v1 nor v2, because it is the counter backing
row lineage.
And from the file layout, a v2-or-later table that has taken row-level deletes will have delete files:
SELECT content, file_format, count(*) FROM ice.blog_format_versions.cdc_orders_v3.files
GROUP BY content, file_format;
content = 1 or 2 cannot exist on a v1 table at all. On v3, a content = 1
row with file_format = PUFFIN is a deletion vector.
What actually changes for a reader
The version is a contract, so it is worth being concrete about what an engine must implement at each level.
| Capability | v1 | v2 | v3+ |
|---|---|---|---|
| Read data files listed in manifests | yes | yes | yes |
| Apply position delete files | — | required | required |
| Apply equality delete files | — | required | required |
| Apply deletion vectors | — | — | required |
| Understand row lineage fields | — | — | for lineage features |
| Column defaults without rewrite | — | — | yes |
An engine that silently ignores a delete file returns deleted rows. That is the failure mode worth fearing when raising a version: not an error, but wrong answers from a reader that parsed what it understood and skipped the rest.
Which is why the upgrade checklist is a reader inventory, not a benchmark. On this stack Trino 483 is the second reader, and it passed: through the same Hive Metastore it returned 4,970 rows for the v2 and v3 merge-on-read tables, 60 cancelled orders in the v3 table with deletion vectors, the same as Spark, and read the v4 table.
An upgrade that is safe to run
Test the upgrade on a copy. The copy has to be an Iceberg table that reads the
same data, and the obvious procedure does not make one: snapshot exists to
turn a Hive or Spark table into Iceberg, and pointed at an Iceberg table it fails:
snapshot: IllegalArgumentException: Cannot create Iceberg table in non-Iceberg Catalog. Catalog 'spark_catalog' was of class 'org.apache.spark.sql.execution.datasources.v2.V2SessionCatalog' but 'org.apache.iceberg.spark.SparkSessionCatalog' or 'org.apache.iceberg.spark.SparkCatalog' are required
register_table does it without copying data: it creates a second catalog entry
for the table’s current metadata.json. upgrade.py does that for a v2
telemetry table of 1,000 events that has already taken one merge-on-read delete:
-- 1. a second table over the same metadata
CALL ice.system.register_table(
table => 'blog_format_versions.events_v3_test',
metadata_file => '<current metadata.json of blog_format_versions.events>');
-- 2. raise the copy
ALTER TABLE ice.blog_format_versions.events_v3_test SET TBLPROPERTIES ('format-version' = '3');
-- 3. exercise the operations that produce the new representations
DELETE FROM ice.blog_format_versions.events_v3_test WHERE id < 100;
UPDATE ice.blog_format_versions.events_v3_test SET region = 'r9' WHERE id BETWEEN 200 AND 300;
source: format-version=2, rows=990, delete files: 1 PARQUET
copy: format-version=3, next-row-id=0; source still format-version=2
rows with a _row_id: 0 of 990
after DELETE : rows=891, delete files: 1 PUFFIN
after UPDATE : rows=891, delete files: 1 PUFFIN, r9 rows=99
copy next-row-id=1099; rows with a _row_id: 891 of 891
source untouched: rows=990, delete files: 1 PARQUET
downgrade: SparkException: Unsupported table change: Cannot downgrade v3 table to v2
Four things in that output matter. The upgrade itself assigned no row ids:
next-row-id was 0 and no existing row had a _row_id until the copy’s own v3
commits. The first v3 DELETE replaced the v2 Parquet position-delete file with
one deletion vector. The source stayed at v2 with its 990 rows. And the way back
is closed.
Never PURGE a registered copy. The copy is not independent: it has the
source’s location, s3a://warehouse/blog_format_versions.db/events, and it
references the source’s files. Dropping it with DROP TABLE ... PURGE while
preparing this post deleted the source’s current metadata.json, and the source
could no longer be loaded (NotFoundException: Failed to open input stream for
file: .../events/metadata/00002-....metadata.json). Drop the copy with a plain
DROP TABLE, which on the Hive catalog only removes the catalog entry.
Then point every reader at events_v3_test — including the ones owned by
other teams — and compare results against the original. Only when all of them
agree do you touch the real table:
ALTER TABLE ice.blog_format_versions.events SET TBLPROPERTIES ('format-version' = '3');
There is no downgrade. If a reader turns out to be incompatible after the fact, the recovery is to rebuild the table at the lower version from a snapshot copy, which is a data-moving job and an outage for writers.
Where v3 pays off, measured in file counts
The argument for deletion vectors is about accumulation, and on Spark the answer
depends on a property most people never set. deletion_vectors.py loads 5,000
orders into one data file per table, then applies the same three DELETEs and
three MERGEs to three merge-on-read tables:
== format-version=2, write.delete.granularity=file ==
after DELETE 1 data files=1 live delete files=1 PARQUET (1.6 KB) delete files ever written=1 rows=4990
after DELETE 3 data files=1 live delete files=1 PARQUET (1.7 KB) delete files ever written=3 rows=4970
after MERGE 3 data files=4 live delete files=1 PARQUET (1.7 KB) delete files ever written=6 rows=4970
== format-version=2, write.delete.granularity=partition ==
after DELETE 1 data files=1 live delete files=1 PARQUET (1.6 KB) delete files ever written=1 rows=4990
after DELETE 3 data files=1 live delete files=3 PARQUET (4.9 KB) delete files ever written=3 rows=4970
after MERGE 3 data files=4 live delete files=6 PARQUET (9.9 KB) delete files ever written=6 rows=4970
== format-version=3, write.delete.granularity=file ==
after DELETE 1 data files=1 live delete files=1 PUFFIN (0.5 KB) delete files ever written=1 rows=4990
after DELETE 3 data files=1 live delete files=1 PUFFIN (0.5 KB) delete files ever written=3 rows=4970
after MERGE 3 data files=4 live delete files=1 PUFFIN (0.5 KB) delete files ever written=6 rows=4970
(The full script prints every step; three are shown per table.)
On Spark, v2 does not accumulate by default. Spark’s write configuration
defaults write.delete.granularity to file (SparkWriteConf.deleteGranularity
in Iceberg 1.11.0), overriding the table-level default of partition. With
file-scoped deletes, each commit rewrites the previous position-delete file for
that data file into a new one: one live file, holding all 90 deleted positions
after six commits. Only with partition granularity, or a writer that does not
rewrite, does each commit leave another file behind: six after six here, and
1,440 a day for a per-minute pipeline.
What v3 changes is the guarantee and the size. A deletion vector is one per data file by definition, whatever the writer’s settings, and at under 0.5 KB it was about a quarter the size of the 1.7 KB Parquet file carrying the same 90 positions. The Puffin sizes were identical on every run; the Parquet ones moved by a few bytes, which is why the script rounds. All three tables returned 4,970 rows and 60 cancelled orders.
That changes the maintenance profile rather than eliminating it: every commit still wrote a new delete file (six ever written in every case), so you still compact data and expire snapshots.
If your tables are append-only, none of this applies and v2 is the end of the story.
v4
v4 is the highest version the current library reads and writes, and v5 is
refused with Unsupported format version: v5 (supported: v4). A table created at
v4 behaves like v3 in the respects measured here, including carrying
next-row-id, and Trino 483 read it.
Being able to write a version is not a reason to use it. The constraint is never your writer; it is every reader that touches the table, including engines outside your control. Treat v4 as available for experiments and v2 or v3 as the production choice, depending on whether all your readers understand deletion vectors.
Upgrading
ALTER TABLE ice.blog_format_versions.events SET TBLPROPERTIES ('format-version' = '3');
Three properties of that statement matter.
It only goes up. There is no downgrade. A table upgraded past what a reader supports is unreadable by that reader until the reader is upgraded, and the recovery is to restore a copy, not to revert the property.
It does not rewrite data. Existing files stay as they are, and existing rows
get no row id from the upgrade itself (next-row-id was 0 afterwards). The
upgrade changes what future writes may do.
It is a compatibility decision, not a performance one. The question is never “is v3 better” — it is “does every engine that reads this table support v3”. Inventory your readers first, including the ones that belong to other teams.
A safe sequence: upgrade a copy, point each reader at the copy, confirm all of them work, then upgrade the original.
Which version to use
| Situation | Version |
|---|---|
| Append-only table, readers of unknown vintage | v1 or v2 |
Anything with UPDATE, DELETE or MERGE |
v2 |
| CDC or streaming upserts with heavy delete churn | v3, if readers support deletion vectors |
| You need to track a row across rewrites | v3 |
| Experimenting with the newest spec | v4 |
v2 is the right default, and it is the default for a reason: it covers row-level operations and is universally supported by engines that support Iceberg at all.
Move to v3 when delete-file accumulation is a measured problem on your CDC tables and you have confirmed every reader handles deletion vectors — not because the number is higher.
Common misconceptions
“v1 tables cannot delete rows.” They can, by rewriting files. v2 added deleting without rewriting.
“The format version controls copy-on-write versus merge-on-read.” The
write.*.mode properties control that. The format version determines whether
merge-on-read is available at all.
“Upgrading improves performance.” It enables representations that can. A v3 table that never uses deletion vectors performs like a v2 table.
“I can downgrade if something breaks.” You cannot. Test on a copy.
“Newer is safer.” Newer is less widely supported. The risk is a reader you forgot about.
Run it yourself
Every output above came from three scripts, run on Spark 3.5.9 with Iceberg 1.11.0 on the local stack described in Run every demo on this blog locally:
git clone https://github.com/rangareddy/datalake-playground.git
cd datalake-playground
SPARK_VERSION=3.5.9 sh run_datalake.sh start
sh run_datalake.sh status
Each script’s docstring has its run command. They use the namespace
ice.blog_format_versions; drop it when you are done.
format_versions.py creates a table at every version, deletes from each, and
tries row lineage and a column default:
"""Create an Iceberg table at every format version and compare what each one writes.
Run inside datalake-playground:
docker cp format_versions.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/format_versions.py'
"""
import json
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("iceberg-format-versions")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_format_versions"
def error(e):
java = getattr(e, "java_exception", None)
if java is not None:
return f"{java.getClass().getSimpleName()}: {java.getMessage().splitlines()[0]}"
return f"{type(e).__name__}: {str(e).splitlines()[0]}"
def current_metadata(table):
"""The table's current metadata.json, parsed."""
path = sql(f"SELECT file FROM {table}.metadata_log_entries "
f"ORDER BY timestamp DESC LIMIT 1").collect()[0][0]
return json.loads(spark.read.text(path, wholetext=True).collect()[0][0])
def file_counts(table):
# .files lists every live file of the current snapshot: data (0), position
# deletes or deletion vectors (1) and equality deletes (2)
rows = sql(f"SELECT content, count(*) AS n FROM {table}.files GROUP BY content").collect()
by = {r.content: r.n for r in rows}
return f"data={by.get(0, 0)} position/DV deletes={by.get(1, 0)} equality deletes={by.get(2, 0)}"
sql(f"CREATE NAMESPACE IF NOT EXISTS {NS}")
print("== 1. every format version ==")
for v in (1, 2, 3, 4, 5):
t = f"{NS}.orders_v{v}"
sql(f"DROP TABLE IF EXISTS {t} PURGE")
try:
sql(f"""CREATE TABLE {t} (order_id BIGINT, region STRING, amount DECIMAL(10,2))
USING iceberg TBLPROPERTIES ('format-version' = '{v}')""")
except Exception as e:
print(f"format-version={v}: CREATE failed: {error(e)}")
continue
sql(f"INSERT INTO {t} VALUES (1001, 'eu', 120.50), (1002, 'us', 75.00), (1003, 'apac', 310.25)")
md = current_metadata(t)
print(f"format-version={v}: CREATE ok, rows={spark.table(t).count()}, "
f"metadata format-version={md['format-version']}, next-row-id={md.get('next-row-id', 'absent')}")
print("\n== 2. the default ==")
sql(f"DROP TABLE IF EXISTS {NS}.orders_default PURGE")
sql(f"CREATE TABLE {NS}.orders_default (order_id BIGINT, region STRING) USING iceberg")
for r in sql(f"SHOW TBLPROPERTIES {NS}.orders_default").collect():
if r.key in ("format-version", "write.parquet.compression-codec"):
print(f"{r.key} = {r.value}")
print("\n== 3. a DELETE on each version (default copy-on-write) ==")
for v in (1, 2, 3, 4):
t = f"{NS}.orders_v{v}"
sql(f"DELETE FROM {t} WHERE order_id = 1002")
op = sql(f"SELECT operation FROM {t}.snapshots ORDER BY committed_at DESC LIMIT 1").collect()[0][0]
print(f"format-version={v} after DELETE: rows={spark.table(t).count()}, {file_counts(t)}, operation={op}")
print("\n== 4. merge-on-read requested on v1 ==")
sql(f"ALTER TABLE {NS}.orders_v1 SET TBLPROPERTIES ('write.delete.mode' = 'merge-on-read')")
try:
sql(f"DELETE FROM {NS}.orders_v1 WHERE order_id = 1003")
print(f"v1 merge-on-read DELETE: rows={spark.table(NS + '.orders_v1').count()}, {file_counts(NS + '.orders_v1')}")
except Exception as e:
print("v1 merge-on-read DELETE:", error(e))
print("\n== 5. row lineage columns ==")
for v in (2, 3):
t = f"{NS}.orders_v{v}"
try:
rows = sql(f"SELECT order_id, _row_id, _last_updated_sequence_number FROM {t} ORDER BY order_id").collect()
print(f"v{v}:", [(r.order_id, r._row_id, r._last_updated_sequence_number) for r in rows])
except Exception as e:
print(f"v{v} lineage columns:", error(e))
sql(f"UPDATE {NS}.orders_v3 SET amount = 99.99 WHERE order_id = 1001")
rows = sql(f"SELECT order_id, _row_id, _last_updated_sequence_number FROM {NS}.orders_v3 ORDER BY order_id").collect()
print("v3 after UPDATE:", [(r.order_id, r._row_id, r._last_updated_sequence_number) for r in rows])
print("v3 next-row-id now:", current_metadata(f"{NS}.orders_v3").get("next-row-id"))
print("\n== 6. a column with a default ==")
for v in (2, 3):
t = f"{NS}.orders_v{v}"
try:
sql(f"ALTER TABLE {t} ADD COLUMN channel STRING DEFAULT 'web'")
print(f"v{v} ADD COLUMN ... DEFAULT: ok, existing rows read",
[r.channel for r in sql(f"SELECT channel FROM {t}").collect()])
except Exception as e:
print(f"v{v} ADD COLUMN ... DEFAULT:", error(e))
spark.stop()
deletion_vectors.py measures delete files on v2 and v3 merge-on-read tables:
"""Compare delete-file accumulation on v2 and v3 merge-on-read tables.
Each table holds 5,000 orders in one data file and takes the same six row-level
commits: three DELETEs and three MERGEs. After every commit the script counts the
live delete files, their format and size, and the delete files ever written.
Three tables: v2 with Spark's default file-scoped deletes, v2 with
'write.delete.granularity' = 'partition', and v3, which uses deletion vectors.
Run inside datalake-playground:
docker cp deletion_vectors.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/deletion_vectors.py'
"""
from pyspark.sql import SparkSession, functions as F
spark = (
SparkSession.builder.appName("iceberg-deletion-vectors")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_format_versions"
orders = (spark.range(1, 5001)
.select(F.col("id").alias("order_id"),
(F.col("id") % 500 + 1).alias("customer_id"),
F.element_at(F.array(F.lit("PLACED"), F.lit("PAID"), F.lit("SHIPPED")),
(F.col("id") % 3 + 1).cast("int")).alias("status"),
(F.col("id") % 400 + 10).cast("decimal(10,2)").alias("amount"))
.coalesce(1))
def state(t):
live = sql(f"SELECT file_format, count(*) AS n, sum(file_size_in_bytes) AS bytes "
f"FROM {t}.files WHERE content = 1 GROUP BY file_format").collect()
ever = sql(f"SELECT count(DISTINCT file_path) FROM {t}.all_delete_files").collect()[0][0]
data = sql(f"SELECT count(*) FROM {t}.files WHERE content = 0").collect()[0][0]
# sizes to 0.1 KB: Parquet delete files vary by a few bytes from run to run
fmt = ", ".join(f"{r.n} {r.file_format} ({r.bytes / 1024:.1f} KB)" for r in live) or "0"
return f"data files={data} live delete files={fmt} delete files ever written={ever} rows={spark.table(t).count()}"
CASES = [("v2", 2, "file"), ("v2_partition", 2, "partition"), ("v3", 3, "file")]
for name, v, granularity in CASES:
t = f"{NS}.cdc_orders_{name}"
sql(f"DROP TABLE IF EXISTS {t} PURGE")
sql(f"""CREATE TABLE {t} (order_id BIGINT, customer_id BIGINT, status STRING, amount DECIMAL(10,2))
USING iceberg TBLPROPERTIES (
'format-version' = '{v}',
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read',
'write.delete.granularity' = '{granularity}')""")
orders.writeTo(t).append()
print(f"== format-version={v}, write.delete.granularity={granularity} ==")
print(" after load ", state(t))
for i, lo in enumerate((100, 200, 300), 1):
sql(f"DELETE FROM {t} WHERE order_id BETWEEN {lo} AND {lo + 9}")
print(f" after DELETE {i} ", state(t))
for i, lo in enumerate((1000, 2000, 3000), 1):
spark.range(lo, lo + 20).select(F.col("id").alias("order_id")).createOrReplaceTempView("changes")
sql(f"""MERGE INTO {t} t USING changes c ON t.order_id = c.order_id
WHEN MATCHED THEN UPDATE SET t.status = 'CANCELLED'""")
print(f" after MERGE {i} ", state(t))
cancelled = sql(f"SELECT count(*) FROM {t} WHERE status = 'CANCELLED'").collect()[0][0]
print(f" cancelled rows = {cancelled}")
spark.stop()
upgrade.py copies a v2 table with register_table, upgrades the copy and
tries to go back:
"""Upgrade an Iceberg table from v2 to v3 on a copy first, and see what changes.
Run inside datalake-playground:
docker cp upgrade.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/upgrade.py'
"""
import json
from pyspark.sql import SparkSession, functions as F
spark = (
SparkSession.builder.appName("iceberg-format-upgrade")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_format_versions"
SRC, COPY = f"{NS}.events", f"{NS}.events_v3_test"
def error(e):
java = getattr(e, "java_exception", None)
if java is not None:
return f"{java.getClass().getSimpleName()}: {java.getMessage().splitlines()[0]}"
return f"{type(e).__name__}: {str(e).splitlines()[0]}"
def metadata(table):
path = sql(f"SELECT file FROM {table}.metadata_log_entries "
f"ORDER BY timestamp DESC LIMIT 1").collect()[0][0]
return path, json.loads(spark.read.text(path, wholetext=True).collect()[0][0])
def version(table):
return [r.value for r in sql(f"SHOW TBLPROPERTIES {table} ('format-version')").collect()][0]
def with_row_id(table):
# count(_row_id) is pushed down to Iceberg, which fails with "Cannot find field
# '_row_id'"; reading the column and counting in Spark avoids the pushdown
ids = [r[0] for r in sql(f"SELECT _row_id FROM {table}").collect()]
return f"{sum(i is not None for i in ids)} of {len(ids)}"
def deletes(table):
rows = sql(f"SELECT file_format, count(*) AS n FROM {table}.files WHERE content = 1 "
f"GROUP BY file_format ORDER BY file_format").collect()
return ", ".join(f"{r.n} {r.file_format}" for r in rows) or "none"
# The registered copy shares the source's files and directory, so it is dropped
# WITHOUT purge: DROP TABLE ... PURGE on the copy deletes the source's files too.
sql(f"DROP TABLE IF EXISTS {COPY}")
sql(f"DROP TABLE IF EXISTS {SRC} PURGE")
# A v2 merge-on-read table of 1,000 telemetry events that has already taken deletes
sql(f"""CREATE TABLE {SRC} (id BIGINT, device STRING, region STRING, reading DOUBLE)
USING iceberg TBLPROPERTIES (
'format-version' = '2',
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read')""")
(spark.range(0, 1000)
.select("id",
F.concat(F.lit("sensor-"), (F.col("id") % 50).cast("string")).alias("device"),
F.concat(F.lit("r"), (F.col("id") % 5).cast("string")).alias("region"),
(F.col("id") % 97 / 10.0).alias("reading"))
.coalesce(1).writeTo(SRC).append())
sql(f"DELETE FROM {SRC} WHERE id % 100 = 0")
print(f"source: format-version={version(SRC)}, rows={spark.table(SRC).count()}, delete files: {deletes(SRC)}")
print("\n== 1. copy with the snapshot procedure ==")
try:
sql(f"CALL ice.system.snapshot('{SRC.split('.', 1)[1]}', '{COPY.split('.', 1)[1]}')").show()
except Exception as e:
print("snapshot:", error(e))
print("\n== 2. copy by registering the same metadata file ==")
src_path, _ = metadata(SRC)
sql(f"CALL ice.system.register_table(table => '{COPY.split('.', 1)[1]}', "
f"metadata_file => '{src_path}')").show(truncate=False)
print(f"copy: format-version={version(COPY)}, rows={spark.table(COPY).count()}")
print("\n== 3. raise the copy ==")
sql(f"ALTER TABLE {COPY} SET TBLPROPERTIES ('format-version' = '3')")
_, md = metadata(COPY)
print(f"copy: format-version={version(COPY)}, next-row-id={md.get('next-row-id')}; "
f"source still format-version={version(SRC)}")
print("rows with a _row_id:", with_row_id(COPY))
print("\n== 4. exercise the new representations on the copy ==")
sql(f"DELETE FROM {COPY} WHERE id < 100")
print(f"after DELETE : rows={spark.table(COPY).count()}, delete files: {deletes(COPY)}")
sql(f"UPDATE {COPY} SET region = 'r9' WHERE id BETWEEN 200 AND 300")
print(f"after UPDATE : rows={spark.table(COPY).count()}, delete files: {deletes(COPY)}, "
f"r9 rows={sql(f'SELECT count(*) FROM {COPY} WHERE region = ' + chr(39) + 'r9' + chr(39)).collect()[0][0]}")
_, md = metadata(COPY)
print(f"copy next-row-id={md.get('next-row-id')}; rows with a _row_id: {with_row_id(COPY)}")
print(f"source untouched: rows={spark.table(SRC).count()}, delete files: {deletes(SRC)}")
print("\n== 5. try to go back ==")
try:
sql(f"ALTER TABLE {COPY} SET TBLPROPERTIES ('format-version' = '2')")
print("downgrade: accepted?!")
except Exception as e:
print("downgrade:", error(e))
print("\n== 6. the copy is not independent ==")
shared = sql(f"""SELECT count(*) FROM {SRC}.files s JOIN {COPY}.all_files c
ON s.file_path = c.file_path""").collect()[0][0]
print("source files also referenced by the copy:", shared)
loc = lambda t: sql(f"DESCRIBE TABLE EXTENDED {t}").where("col_name = 'Location'").collect()[0][1]
print("source location:", loc(SRC))
print("copy location :", loc(COPY))
sql(f"DROP TABLE {COPY}") # never PURGE a registered copy
print("after dropping the copy (no PURGE), source rows:", spark.table(SRC).count())
spark.stop()
A model worth keeping
v1 made a table a tracked list of files. v2 made a row removable without rewriting its file. v3 made that removal a bitmap and gave rows an identity. v4 is where the spec is being written next.
The version is a contract with your readers, not a tuning knob. Pick the lowest version that expresses what your workload needs, and upgrade when a measured problem — not a release note — requires it.
References
- Iceberg table specification for the authoritative per-version field lists
- Iceberg spec: delete formats for position, equality deletes and deletion vectors
- Apache Iceberg on Spark: the complete cheat sheet for the version table and supported constants
- Copy-on-write or merge-on-read for what delete files cost in practice
- CDC into Iceberg for the accumulation deletion vectors target
Trademarks
Apache Iceberg, Apache Spark, Apache Flink, Apache Parquet, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.