All posts

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.

26 min read Iceberg

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.json and nothing else.
  • Columns are identified by field id, never by name or position. amount renamed to amount_usd kept id 4; the Parquet files never learned about it.
  • A new column reads as NULL on old files: all 9,000 pre-existing rows returned NULL for currency.
  • 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

Renaming a column changes only the schema's label for field id 4; the data files, which record field ids rather than names, are untouched, and the newly added field id 5 reads NULL on files written before it existed

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

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.

Buy me a coffee