Schema evolution in Iceberg: why a rename costs nothing
Renaming a column in an Iceberg table rewrote no data and kept the column's field id. That identifier is the whole mechanism, and it is why schema changes here do not have the failure modes they have on Hive tables.
- The mechanism is one integer
- What a schema change costs
- The operations, and their limits
- Nested fields get ids too
- Type promotion, precisely
- Doing a narrowing change anyway
- Checking what a schema change will affect
- What this replaces
- Doing it safely
- Run it yourself
- Common misconceptions
- A model worth keeping
- References
- Trademarks
TL;DR
- Adding a column, renaming one and changing a type left the table at 3 data files before and 3 after. Each change wrote one
metadata.jsonand nothing else.- Columns are identified by field id, never by name or position.
amountrenamed toamount_usdkept id 4; the Parquet files never learned about it.- A new column reads as
NULLon old files: all 9,000 pre-existing rows returnedNULLforcurrency.- Dropped ids are retired, never reused: after dropping id 5, the next new column got id 6.
- On a Hive Metastore catalog, dropping any column but the last, and every reorder, fails with
CommitStateUnknownException, because the metastore still checks column types by position.- A dropped column stays readable through time travel until its snapshots are expired, not merely until the files are rewritten.
The mechanism is one integer
Every column in an Iceberg schema has a numeric id assigned when the column is created. Data files record values against those ids. The schema maps ids to names.
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=5 name=currency type=string

That listing was read from the table’s current metadata.json after renaming
amount to amount_usd and adding currency. amount_usd is id 4, the id
amount had. The rename changed the schema’s label for id 4 and nothing else.
Files written when the column was called amount are still correct, because they
never recorded the name.
This is why the operation is instant regardless of table size, and why it is safe.
What a schema change costs
Three changes against a table of 9,000 purchase events in 3 data files:
ALTER TABLE ice.blog_schema_evolution.events ADD COLUMN currency STRING;
ALTER TABLE ice.blog_schema_evolution.events RENAME COLUMN amount TO amount_usd;
ALTER TABLE ice.blog_schema_evolution.events ALTER COLUMN id TYPE BIGINT;
before: ['id', 'event_ts', 'region', 'amount']
after : ['id', 'event_ts', 'region', 'amount_usd', 'currency']
rows still readable = 9000
old rows have NULL currency = 9000
data files before / after = 3 / 3
metadata.json files before / after = 4 / 7
Three files before, three after. All 9,000 rows readable. Every pre-existing
row returns NULL for the new column, because the files contain no value for id
5 and the reader fills the gap from the schema rather than failing.
The cost of a schema change is one new metadata.json: three ALTERs took the
table from 4 metadata files to 7. That is the entire bill.
The operations, and their limits
| Operation | Allowed by Iceberg | On a Hive Metastore catalog |
|---|---|---|
| Add a column at the end | yes, reads NULL on existing files |
yes |
Add a column with FIRST or AFTER |
yes | fails, see below |
| Drop the last column | yes, id retired | yes |
| Drop any other column | yes, id retired | fails, see below |
| Rename a column | yes, metadata only | yes |
| Reorder columns | yes, order is presentation | fails, see below |
| Widen a type | yes: int→long, float→double, decimal precision up |
yes |
| Narrow a type | no, refused by Spark | no |
| Add a nested field | yes, struct fields get ids too | yes |
Widening is permitted because every existing value remains representable. An
int stored in a file is still a valid long. Narrowing is refused because the
engine cannot know that every value in every file fits, and discovering otherwise
mid-query would mean silent corruption or a failure deep in a scan.
Genuinely narrowing a type is a rewrite: add a new column, populate it, verify, drop the old one.
On a Hive catalog, position still matters
The table format keys columns by id. The Hive Metastore behind a type=hive
catalog does not. On every commit, Iceberg’s Hive catalog copies the current
schema into the metastore’s table definition, and the metastore compares the new
column list against the old one by position: its
hive.metastore.disallow.incompatible.col.type.changes check defaults to true.
Any change that moves a column into a slot that held a different type is refused:
DROP COLUMN region (not last): CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : amount_usd
columns after the failed drop: ['id', 'event_ts', 'region', 'amount_usd', 'currency']
metadata.json in the table log / on storage: 7 / 8
ALTER COLUMN currency FIRST: CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : id,event_ts,amount_usd
ADD COLUMN channel AFTER id: CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : event_ts,amount_usd
Dropping region would slide amount_usd, a decimal, into the slot that held a
string, so the metastore rejects the commit. Three details matter when you meet
this in production:
The exception is CommitStateUnknownException, not a validation error. Iceberg
cannot tell whether the metastore applied the change, so it tells you to check
before retrying. Here nothing was applied: region is still in the schema and
the table log still has 7 entries.
Each failed commit leaves an orphan metadata.json. Storage held 8 metadata
files for a log of 7. DROP TABLE ... PURGE deletes only files the table
references, so the orphans outlive the table; after purging this table, 7 of them
were still on MinIO.
The fix is on the metastore, not in Spark. Iceberg’s own documentation for
this limitation says to set
hive.metastore.disallow.incompatible.col.type.changes=false in the metastore’s
hive-site.xml for a remote metastore, at the cost that the Hive engine may no
longer read the table correctly. The alternative is a catalog that keeps no Hive
column list: the Hadoop catalog used later in this post completed the same drop,
and Iceberg’s documentation files the restriction as a Hive catalog limitation.
The datalake-playground metastore uses the
default, so on this stack: add columns at the end, drop only the last column,
and do not reorder.
Dropped ids are retired
The last column can be dropped on any catalog. Drop currency, which is id 5,
and add country:
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=6 name=country type=string
country values on old rows: [None]
last-column-id: 6
country got id 6, not the freed 5, and last-column-id in the metadata only
ever grows. If ids were recycled, a new column reusing id 5 would read old
currency values as its own: wrong data, no error. Retirement is why Iceberg
does not have that failure and name-matched formats do.
Nested fields get ids too
The rule extends all the way down. Struct fields, map keys and list elements each carry their own id, so evolution works inside a nested column exactly as it does at the top level.
ALTER TABLE ice.blog_schema_evolution.events
ADD COLUMN payload struct<source: string, version: int>;
ALTER TABLE ice.blog_schema_evolution.events
ADD COLUMN payload.trace_id string; -- add inside the struct
ALTER TABLE ice.blog_schema_evolution.events
RENAME COLUMN payload.source TO origin; -- the new name is the leaf only
The rename target is the leaf name. Writing TO payload.origin is a parse
error, [PARSE_SYNTAX_ERROR] Syntax error at or near '.'. Reading the metadata
afterwards shows the nested ids alongside the top-level ones:
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=6 name=country type=string
field id=7 name=payload type=struct
field id=8 name=origin type=string
field id=9 name=version type=int
field id=10 name=trace_id type=string
payload on old rows: [None]
data files now: 3
Adding a field inside a struct is as cheap as adding a top-level column, and
existing files read it as NULL. The table still has its original 3 data files.
This is the part that tends to surprise people coming from formats where a nested
change means a rewrite.
Type promotion, precisely
“Widening is allowed” is the shape of the rule; the spec is specific about which
promotions exist, and Iceberg’s TypeUtil.isPromotionAllowed implements exactly
these.
| From | To | Allowed |
|---|---|---|
int |
long |
yes |
float |
double |
yes |
decimal(P, S) |
decimal(P', S) where P' > P |
yes, scale must match |
long |
int |
no |
double |
float |
no |
decimal(P, S) |
any different scale | no |
string |
int |
no |
Tried one by one against a 3,000-row orders table:
qty int -> bigint accepted
weight float -> double accepted
amount decimal(10,2) -> decimal(18,2) accepted
amount decimal(18,2) -> decimal(18,4) AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] DECIMAL(18,2) -> DECIMAL(18,4)
order_id bigint -> int AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] BIGINT -> INT
weight double -> float AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] DOUBLE -> FLOAT
customer string -> int AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] STRING -> INT
schema now: [('order_id', 'bigint'), ('amount', 'decimal(18,2)'), ('weight', 'double'), ('qty', 'bigint'), ('customer', 'string')]
data files: 1 | rows: 3000
The refusals come from Spark’s analyzer, NOT_SUPPORTED_CHANGE_COLUMN, before
the request reaches Iceberg. The three widenings changed only metadata: the
table still has its one data file.
The scale restriction on decimals catches people: you can make a decimal hold bigger numbers, not more fractional digits, because rescaling would change existing values rather than merely reinterpret them.
ALTER TABLE ice.blog_schema_evolution.orders ALTER COLUMN amount TYPE decimal(18,2); -- from (10,2): ok
ALTER TABLE ice.blog_schema_evolution.orders ALTER COLUMN amount TYPE decimal(18,4); -- refused
Doing a narrowing change anyway
When you genuinely need a change the rules forbid, the pattern is add-migrate-drop, and it is worth writing down because doing it in the wrong order loses data.
-- 1. add the new column; costs one metadata write
ALTER TABLE ice.blog_schema_evolution.orders ADD COLUMN amount_small float;
-- 2. populate it; this is the expensive step and it rewrites data
UPDATE ice.blog_schema_evolution.orders SET amount_small = CAST(amount AS float);
-- 3. verify before you drop anything
SELECT count(*) FROM ice.blog_schema_evolution.orders
WHERE amount_small IS NULL AND amount IS NOT NULL;
-- 4. only then retire the old column
ALTER TABLE ice.blog_schema_evolution.orders DROP COLUMN amount;
== 2. add-migrate-drop on the Hive catalog ==
UPDATE commit: overwrite | added-data-files = 1 | deleted-data-files = 1
rows where the cast lost a value: 0
DROP COLUMN amount CommitStateUnknownException: columns incompatible in their positions: qty,amount_small
columns: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small']
The UPDATE rewrote the table’s data file (an overwrite commit that added one
file and deleted one), and the check in step 3 found nothing lost. Step 4 fails
on a Hive catalog, for the reason in the previous section: the new column is
always last, so the old one is never last. On this stack the pattern stops at
step 3 unless the metastore check is switched off.
Step 3 is not optional. A CAST that silently produced NULL for out-of-range
values is exactly the case the type rules were protecting you from, and this
query is how you find out before the original is gone.
What a drop leaves behind
Run on a Hadoop catalog on the same MinIO bucket, which keeps no Hive column list, the same four steps complete, and the recovery window can be measured:
== 3. the same pattern on a Hadoop catalog ==
rows where the cast lost a value: 0
DROP COLUMN amount accepted
columns: ['order_id', 'weight', 'qty', 'customer', 'amount_small']
columns physically in a current data file: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small']
rewrite_data_files rewrote 1 file(s) into 1
columns physically in the rewritten file: ['order_id', 'weight', 'qty', 'customer', 'amount_small']
time travel to before the drop, after the rewrite: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small'] | sum(amount) = 1259970.00
time travel after expire_snapshots: IllegalArgumentException - Cannot find snapshot with ID 8995773699567725794
The drop is metadata only. The current data file still holds the amount
bytes. rewrite_data_files writes new files without the column, but the old
files are still referenced by the snapshots before the drop, so time travel to
one of them read every amount value back. The column is truly gone only after
expire_snapshots removes those snapshots:
-- new files lack the column; old snapshots can still read it
CALL hdp.system.rewrite_data_files(table => 'db.orders',
options => map('rewrite-all', 'true'));
-- after this, it is gone
CALL hdp.system.expire_snapshots(table => 'db.orders',
older_than => TIMESTAMP '2099-01-01 00:00:00', retain_last => 1);
rewrite-all is there because the table has a single small file, which the
default thresholds would skip. expire_snapshots with no arguments keeps
anything younger than five days, so right after a drop it would expire nothing;
the explicit older_than and retain_last => 1 keep only the current snapshot.
A Hadoop catalog on object storage is fine for a demonstration and wrong for production: it commits by renaming a metadata file, and S3-style stores have no atomic rename to make concurrent commits safe.
Checking what a schema change will affect
The table tolerates the change; your consumers may not. Before renaming or dropping anything:
-- which schema each metadata version's latest snapshot was written under
SELECT latest_snapshot_id, latest_schema_id
FROM ice.blog_schema_evolution.events.metadata_log_entries ORDER BY timestamp;
-- what the current schema is, field by field
DESCRIBE TABLE ice.blog_schema_evolution.events;
The snapshots metadata table has no schema_id column: its columns are
committed_at, snapshot_id, parent_id, operation, manifest_list and
summary, and asking for schema_id fails with UNRESOLVED_COLUMN. The schema
id per snapshot is in metadata.json, and metadata_log_entries exposes it as
latest_schema_id. After the eight schema changes above, all three snapshots
still reported schema 0, the schema they were written under, while the table’s
current schema was 8.
That per-snapshot schema id is what makes time travel safe. Reading the first
snapshot returned its own column names, ['id', 'event_ts', 'region', 'amount'],
not today’s amount_usd, so reading history does not reinterpret old files
through today’s schema.
What this replaces
On a Hive table, columns are matched by name or by position, depending on the reader, and both break.
By position: inserting a column in the middle shifts everything after it. Old
files then line up against the wrong schema entries, and you read a region value
as a currency. No error, just wrong answers.
By name: renaming a column makes old files stop matching. The column reads
NULL for all historical data, which looks like a data quality problem rather
than a schema problem, and gets investigated as one.
Both are silent. Iceberg’s ids make both impossible: files record ids, the schema maps ids to names, and renaming or reordering touches only the map. The one place the old positional model survives is the Hive Metastore’s own column check, which fails loudly rather than silently.
Doing it safely
The mechanism is safe; the coordination around it still is not.
Adding a column is safe to do at any time. Readers that do not know about it
ignore it; readers that do get NULL for history. Add it at the end if the
catalog is a Hive Metastore.
Renaming is safe for the table and not for your consumers. Every query, dashboard and downstream job naming the old column breaks at once. The table is fine; the ecosystem is not. Prefer adding the new name and migrating consumers before dropping the old one, exactly as with a database column.
Dropping is the one to stage. The data stays in the files, so the column is
hidden, not deleted, and old snapshots can still read it after the files are
rewritten. It is recoverable until expire_snapshots removes the snapshots from
before the drop. Treat the window between the ALTER and that expiry as your
grace period, and on a Hive catalog check first whether the column is last.
Check what reads the table first. The schema change is a metadata edit; the blast radius is every consumer.
Run it yourself
Every number above came from the two scripts below, run on the local stack described in Run every demo on this blog locally with Spark 3.5.9 and Iceberg 1.11.0. Start it with:
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
Both scripts use the namespace ice.blog_schema_evolution; the second also
creates a Hadoop catalog under s3a://warehouse/blog_schema_evolution_hadoop.
They drop their tables before recreating them. Snapshot ids will differ from run
to run; the counts and field ids will not. Because the failed commits leave
orphan metadata files, remove the two prefixes when you are done:
docker exec mc /usr/bin/mc rm --recursive --force \
minio/warehouse/blog_schema_evolution.db/ \
minio/warehouse/blog_schema_evolution_hadoop/
Script 1: what each change costs, field ids, drops and nested fields
Save as schema_evolution.py:
"""Schema evolution on an Iceberg events table: what each change costs, and field ids.
Run inside datalake-playground:
docker cp schema_evolution.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/schema_evolution.py'
"""
import json
from datetime import datetime, timedelta
from decimal import Decimal
from pyspark.sql import SparkSession
from pyspark.sql.types import (DecimalType, IntegerType, StringType, StructField,
StructType, TimestampType)
spark = (
SparkSession.builder.appName("iceberg-schema-evolution")
.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
TABLE = "ice.blog_schema_evolution.events"
REGIONS = ["us-east", "us-west", "eu-central"]
SCHEMA = StructType([
StructField("id", IntegerType(), False),
StructField("event_ts", TimestampType(), False),
StructField("region", StringType(), False),
StructField("amount", DecimalType(10, 2), False),
])
def batch(start):
"""3,000 purchase events, one data file."""
t0 = datetime(2026, 9, 1)
rows = [(i, t0 + timedelta(minutes=i), REGIONS[i % 3], Decimal(i % 500) + Decimal("0.99"))
for i in range(start, start + 3000)]
spark.createDataFrame(rows, SCHEMA).coalesce(1).writeTo(TABLE).append()
def columns():
return spark.table(TABLE).columns
def data_files():
return sql(f"SELECT count(*) FROM {TABLE}.files").collect()[0][0]
def metadata_files():
return sql(f"SELECT count(*) FROM {TABLE}.metadata_log_entries").collect()[0][0]
def current_metadata():
"""Read the table's current metadata.json straight from MinIO."""
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 print_fields(fields, depth=0):
for f in fields:
t = f["type"]
type_name = t["type"] if isinstance(t, dict) else t
print(" " * depth + "field id=%-3s name=%-12s type=%s" % (f["id"], f["name"], type_name))
if isinstance(t, dict) and t.get("type") == "struct":
print_fields(t["fields"], depth + 1)
def current_schema_fields():
md = current_metadata()
schema = next(s for s in md["schemas"] if s["schema-id"] == md["current-schema-id"])
return schema["fields"]
sql("CREATE NAMESPACE IF NOT EXISTS ice.blog_schema_evolution")
sql(f"DROP TABLE IF EXISTS {TABLE} PURGE")
sql(f"""CREATE TABLE {TABLE} (id INT, event_ts TIMESTAMP, region STRING, amount DECIMAL(10,2))
USING iceberg""")
for start in (0, 3000, 6000):
batch(start)
first_snapshot = sql(f"SELECT snapshot_id FROM {TABLE}.history "
f"ORDER BY made_current_at LIMIT 1").collect()[0][0]
print("== 1. three changes, measured ==")
print("before:", columns())
files_before, metadata_before = data_files(), metadata_files()
sql(f"ALTER TABLE {TABLE} ADD COLUMN currency STRING")
sql(f"ALTER TABLE {TABLE} RENAME COLUMN amount TO amount_usd")
sql(f"ALTER TABLE {TABLE} ALTER COLUMN id TYPE BIGINT")
print("after :", columns())
print("rows still readable =", spark.table(TABLE).count())
print("old rows have NULL currency =",
sql(f"SELECT count(*) FROM {TABLE} WHERE currency IS NULL").collect()[0][0])
print("data files before / after =", files_before, "/", data_files())
print("metadata.json files before / after =", metadata_before, "/", metadata_files())
print("\n== 2. field ids after the rename ==")
print_fields(current_schema_fields())
print("\n== 3. dropping and reordering on a Hive catalog ==")
def metadata_json_on_storage():
"""Count metadata.json files in the table's metadata directory on MinIO."""
location = sql(f"DESCRIBE TABLE EXTENDED {TABLE}").where("col_name = 'Location'").collect()[0][1]
jvm = spark._jvm
path = jvm.org.apache.hadoop.fs.Path(location + "/metadata")
fs = path.getFileSystem(spark._jsc.hadoopConfiguration())
return sum(1 for st in fs.listStatus(path) if st.getPath().getName().endswith(".metadata.json"))
def attempt(label, statement):
try:
sql(statement)
print(f"{label}: accepted")
except Exception as e:
java = getattr(e, "java_exception", None)
name = java.getClass().getName().split(".")[-1] if java is not None else type(e).__name__
msg = (java.getMessage() if java is not None else str(e)).splitlines()
print(f"{label}: {name} - {msg[0]} {msg[1] if len(msg) > 1 else ''}".rstrip())
attempt("DROP COLUMN region (not last)", f"ALTER TABLE {TABLE} DROP COLUMN region")
print("columns after the failed drop:", columns())
print("metadata.json in the table log / on storage:", metadata_files(), "/", metadata_json_on_storage())
attempt("ALTER COLUMN currency FIRST", f"ALTER TABLE {TABLE} ALTER COLUMN currency FIRST")
attempt("ADD COLUMN channel AFTER id", f"ALTER TABLE {TABLE} ADD COLUMN channel STRING AFTER id")
print("\n-- drop the last column, then add a new one --")
sql(f"ALTER TABLE {TABLE} DROP COLUMN currency")
sql(f"ALTER TABLE {TABLE} ADD COLUMN country STRING")
print_fields(current_schema_fields())
print("country values on old rows:",
[r[0] for r in sql(f"SELECT DISTINCT country FROM {TABLE}").collect()])
print("last-column-id:", current_metadata()["last-column-id"])
print("\n== 4. nested fields ==")
sql(f"ALTER TABLE {TABLE} ADD COLUMN payload struct<source: string, version: int>")
sql(f"ALTER TABLE {TABLE} ADD COLUMN payload.trace_id string")
try:
sql(f"ALTER TABLE {TABLE} RENAME COLUMN payload.source TO payload.origin")
print("RENAME ... TO payload.origin: accepted")
except Exception as e:
print("RENAME ... TO payload.origin:", type(e).__name__, "-", " ".join(str(e).split())[:90])
sql(f"ALTER TABLE {TABLE} RENAME COLUMN payload.source TO origin")
print_fields(current_schema_fields())
print("payload on old rows:",
[r[0] for r in sql(f"SELECT DISTINCT payload FROM {TABLE}").collect()])
print("data files now:", data_files())
print("\n== 5. old snapshots keep their schema ==")
old = spark.read.option("snapshot-id", str(first_snapshot)).format("iceberg").load(TABLE)
print("columns at the first snapshot:", old.columns)
print("columns now :", columns())
try:
sql(f"SELECT snapshot_id, schema_id FROM {TABLE}.snapshots").collect()
print("snapshots.schema_id: exists")
except Exception as e:
print("snapshots.schema_id:", type(e).__name__, "-", str(e).splitlines()[0][:60])
print("snapshots columns:", spark.table(f"{TABLE}.snapshots").columns)
sql(f"""SELECT latest_snapshot_id, latest_schema_id
FROM {TABLE}.metadata_log_entries ORDER BY timestamp""").show(20, truncate=False)
md = current_metadata()
print("schema-id per snapshot (from metadata.json):",
[s.get("schema-id") for s in md["snapshots"]])
print("schemas in metadata.json:", len(md["schemas"]),
"| current-schema-id:", md["current-schema-id"])
spark.stop()
It printed:
== 1. three changes, measured ==
before: ['id', 'event_ts', 'region', 'amount']
after : ['id', 'event_ts', 'region', 'amount_usd', 'currency']
rows still readable = 9000
old rows have NULL currency = 9000
data files before / after = 3 / 3
metadata.json files before / after = 4 / 7
== 2. field ids after the rename ==
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=5 name=currency type=string
== 3. dropping and reordering on a Hive catalog ==
DROP COLUMN region (not last): CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : amount_usd
columns after the failed drop: ['id', 'event_ts', 'region', 'amount_usd', 'currency']
metadata.json in the table log / on storage: 7 / 8
ALTER COLUMN currency FIRST: CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : id,event_ts,amount_usd
ADD COLUMN channel AFTER id: CommitStateUnknownException - The following columns have types incompatible with the existing columns in their respective positions : event_ts,amount_usd
-- drop the last column, then add a new one --
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=6 name=country type=string
country values on old rows: [None]
last-column-id: 6
== 4. nested fields ==
RENAME ... TO payload.origin: ParseException - [PARSE_SYNTAX_ERROR] Syntax error at or near '.'.(line 1, pos 84) == SQL == ALTER TABLE ic
field id=1 name=id type=long
field id=2 name=event_ts type=timestamptz
field id=3 name=region type=string
field id=4 name=amount_usd type=decimal(10, 2)
field id=6 name=country type=string
field id=7 name=payload type=struct
field id=8 name=origin type=string
field id=9 name=version type=int
field id=10 name=trace_id type=string
payload on old rows: [None]
data files now: 3
== 5. old snapshots keep their schema ==
columns at the first snapshot: ['id', 'event_ts', 'region', 'amount']
columns now : ['id', 'event_ts', 'region', 'amount_usd', 'country', 'payload']
snapshots.schema_id: AnalysisException - [UNRESOLVED_COLUMN.WITH_SUGGESTION] A column or function par
snapshots columns: ['committed_at', 'snapshot_id', 'parent_id', 'operation', 'manifest_list', 'summary']
+-------------------+----------------+
|latest_snapshot_id |latest_schema_id|
+-------------------+----------------+
|NULL |NULL |
|7670258242583182850|0 |
|2279645368980199991|0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
|101118016673128627 |0 |
+-------------------+----------------+
schema-id per snapshot (from metadata.json): [0, 0, 0]
schemas in metadata.json: 9 | current-schema-id: 8
Script 2: type promotion and add-migrate-drop
Save as type_promotion.py:
"""Type promotion rules and the add-migrate-drop pattern on an Iceberg table.
`ice` is the stack's Hive Metastore catalog. `hdp` is a Hadoop catalog on the same
MinIO bucket, used to show the pattern on a catalog without the metastore's
positional column check.
Run inside datalake-playground:
docker cp type_promotion.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/type_promotion.py'
"""
import re
from decimal import Decimal
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("iceberg-type-promotion")
.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.sql.catalog.hdp", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.hdp.type", "hadoop")
.config("spark.sql.catalog.hdp.warehouse", "s3a://warehouse/blog_schema_evolution_hadoop")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
def attempt(label, statement):
try:
sql(statement)
print(f" {label:<44} accepted")
except Exception as e:
java = getattr(e, "java_exception", None)
name = java.getClass().getName().split(".")[-1] if java is not None else type(e).__name__
msg = " ".join((java.getMessage() if java is not None else str(e)).split())
types = re.findall(r'with type "([^"]+)"', msg)
code = re.match(r"\[([A-Z_]+)\]", msg)
if code and len(types) == 2:
msg = f"[{code.group(1)}] {types[0]} -> {types[1]}"
elif "respective positions" in msg:
cols = re.search(r"respective positions : ([A-Za-z0-9_,]+)", msg).group(1)
msg = "columns incompatible in their positions: " + cols
print(f" {label:<44} {name}: {msg[:110]}")
def orders(table):
"""Load 3,000 orders as one data file."""
rows = [(i, Decimal(i % 900) + Decimal("0.49"), float(i % 97) / 7, i % 5, f"C{i % 40:03d}")
for i in range(3000)]
(spark.createDataFrame(rows, "order_id BIGINT, amount DECIMAL(10,2), weight FLOAT, "
"qty INT, customer STRING")
.coalesce(1).writeTo(table).append())
def files(table):
return sql(f"SELECT count(*) FROM {table}.files").collect()[0][0]
# ------------------------------------------------------------------ promotions
T = "ice.blog_schema_evolution.orders"
sql("CREATE NAMESPACE IF NOT EXISTS ice.blog_schema_evolution")
sql(f"DROP TABLE IF EXISTS {T} PURGE")
sql(f"""CREATE TABLE {T} (order_id BIGINT, amount DECIMAL(10,2), weight FLOAT, qty INT,
customer STRING) USING iceberg""")
orders(T)
print("== 1. type promotion ==")
attempt("qty int -> bigint", f"ALTER TABLE {T} ALTER COLUMN qty TYPE BIGINT")
attempt("weight float -> double", f"ALTER TABLE {T} ALTER COLUMN weight TYPE DOUBLE")
attempt("amount decimal(10,2) -> decimal(18,2)", f"ALTER TABLE {T} ALTER COLUMN amount TYPE DECIMAL(18,2)")
attempt("amount decimal(18,2) -> decimal(18,4)", f"ALTER TABLE {T} ALTER COLUMN amount TYPE DECIMAL(18,4)")
attempt("order_id bigint -> int", f"ALTER TABLE {T} ALTER COLUMN order_id TYPE INT")
attempt("weight double -> float", f"ALTER TABLE {T} ALTER COLUMN weight TYPE FLOAT")
attempt("customer string -> int", f"ALTER TABLE {T} ALTER COLUMN customer TYPE INT")
print(" schema now:", [(f.name, f.dataType.simpleString()) for f in spark.table(T).schema])
print(" data files:", files(T), "| rows:", spark.table(T).count())
# ------------------------------------------- add-migrate-drop on the Hive catalog
print("\n== 2. add-migrate-drop on the Hive catalog ==")
sql(f"ALTER TABLE {T} ADD COLUMN amount_small FLOAT")
sql(f"UPDATE {T} SET amount_small = CAST(amount AS FLOAT)")
summary = sql(f"SELECT operation, summary FROM {T}.snapshots ORDER BY committed_at DESC LIMIT 1").collect()[0]
print(" UPDATE commit:", summary["operation"], "| added-data-files =", summary["summary"]["added-data-files"],
"| deleted-data-files =", summary["summary"]["deleted-data-files"])
print(" rows where the cast lost a value:",
sql(f"SELECT count(*) FROM {T} WHERE amount_small IS NULL AND amount IS NOT NULL").collect()[0][0])
attempt("DROP COLUMN amount", f"ALTER TABLE {T} DROP COLUMN amount")
print(" columns:", spark.table(T).columns)
sql(f"DROP TABLE {T} PURGE")
# ------------------------------------------ the same pattern on a Hadoop catalog
print("\n== 3. the same pattern on a Hadoop catalog ==")
H = "hdp.db.orders"
sql(f"DROP TABLE IF EXISTS {H} PURGE")
sql(f"""CREATE TABLE {H} (order_id BIGINT, amount DECIMAL(10,2), weight FLOAT, qty INT,
customer STRING) USING iceberg""")
orders(H)
sql(f"ALTER TABLE {H} ADD COLUMN amount_small FLOAT")
sql(f"UPDATE {H} SET amount_small = CAST(amount AS FLOAT)")
print(" rows where the cast lost a value:",
sql(f"SELECT count(*) FROM {H} WHERE amount_small IS NULL AND amount IS NOT NULL").collect()[0][0])
before_drop = sql(f"SELECT snapshot_id FROM {H}.refs WHERE name = 'main'").collect()[0][0]
attempt("DROP COLUMN amount", f"ALTER TABLE {H} DROP COLUMN amount")
print(" columns:", spark.table(H).columns)
data_file = sql(f"SELECT file_path FROM {H}.files LIMIT 1").collect()[0][0]
print(" columns physically in a current data file:", spark.read.parquet(data_file).columns)
result = sql(f"CALL hdp.system.rewrite_data_files(table => 'db.orders', "
f"options => map('rewrite-all', 'true'))").collect()[0]
print(" rewrite_data_files rewrote", result["rewritten_data_files_count"], "file(s) into",
result["added_data_files_count"])
new_file = sql(f"SELECT file_path FROM {H}.files LIMIT 1").collect()[0][0]
print(" columns physically in the rewritten file:", spark.read.parquet(new_file).columns)
old = spark.read.option("snapshot-id", str(before_drop)).format("iceberg").load(H)
print(" time travel to before the drop, after the rewrite:", old.columns,
"| sum(amount) =", old.agg({"amount": "sum"}).collect()[0][0])
sql(f"CALL hdp.system.expire_snapshots(table => 'db.orders', "
f"older_than => TIMESTAMP '2099-01-01 00:00:00', retain_last => 1)").collect()
try:
spark.read.option("snapshot-id", str(before_drop)).format("iceberg").load(H).count()
print(" time travel after expire_snapshots: still readable")
except Exception as e:
print(" time travel after expire_snapshots:", type(e).__name__, "-", str(e).splitlines()[0][:90])
sql(f"DROP TABLE {H} PURGE")
spark.stop()
It printed:
== 1. type promotion ==
qty int -> bigint accepted
weight float -> double accepted
amount decimal(10,2) -> decimal(18,2) accepted
amount decimal(18,2) -> decimal(18,4) AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] DECIMAL(18,2) -> DECIMAL(18,4)
order_id bigint -> int AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] BIGINT -> INT
weight double -> float AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] DOUBLE -> FLOAT
customer string -> int AnalysisException: [NOT_SUPPORTED_CHANGE_COLUMN] STRING -> INT
schema now: [('order_id', 'bigint'), ('amount', 'decimal(18,2)'), ('weight', 'double'), ('qty', 'bigint'), ('customer', 'string')]
data files: 1 | rows: 3000
== 2. add-migrate-drop on the Hive catalog ==
UPDATE commit: overwrite | added-data-files = 1 | deleted-data-files = 1
rows where the cast lost a value: 0
DROP COLUMN amount CommitStateUnknownException: columns incompatible in their positions: qty,amount_small
columns: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small']
== 3. the same pattern on a Hadoop catalog ==
rows where the cast lost a value: 0
DROP COLUMN amount accepted
columns: ['order_id', 'weight', 'qty', 'customer', 'amount_small']
columns physically in a current data file: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small']
rewrite_data_files rewrote 1 file(s) into 1
columns physically in the rewritten file: ['order_id', 'weight', 'qty', 'customer', 'amount_small']
time travel to before the drop, after the rewrite: ['order_id', 'amount', 'weight', 'qty', 'customer', 'amount_small'] | sum(amount) = 1259970.00
time travel after expire_snapshots: IllegalArgumentException - Cannot find snapshot with ID 8995773699567725794
Common misconceptions
“Adding a column rewrites the table.” It writes one metadata file. The measured change left the data file count unchanged.
“Renaming breaks old data.” Old files are keyed by id. The measured rename kept id 4 and all 9,000 rows.
“NULLs after adding a column mean something went wrong.” They mean the files predate the column. That is the defined behaviour.
“I can change any type.” Only where every existing value stays valid. Narrowing is refused.
“Iceberg lets me drop or reorder any column.” The format does. A Hive Metastore catalog refuses both unless the change keeps every column’s type in its position.
“Dropping a column frees space.” It hides the column. The bytes go when the files are rewritten and the snapshots that still reference the old files expire.
A model worth keeping
The schema is a map from ids to names and types. Data files are keyed by id. Schema evolution edits the map.
Which is why renaming is free, reordering is free, adding is free, and the only expensive operation is the one that would make existing values invalid, because that is the only one that requires touching the data. The exception is the catalog: a Hive Metastore keeps its own positional copy of the column list and can veto a change the format allows.
References
- Iceberg schema evolution for the permitted changes
- Iceberg Spark DDL for the Hive catalog limitation on reordering and dropping
- Iceberg table specification for field ids and type promotion rules
- Apache Iceberg architecture for where the schema lives in the metadata tree
- Hidden partitioning and partition evolution for the same trick applied to layout
- What Hive tables could not do for the failure modes this replaces
Trademarks
Apache Iceberg, Apache Spark, Apache Hive, Apache Parquet, Apache Hadoop, 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.