Migrating Hive tables to Iceberg: snapshot first, migrate second
Two procedures convert a Hive table to Iceberg without rewriting data. One makes a parallel copy you can test against while the original keeps serving; the other converts in place. Here is what each does to the original table and its files, measured against a Hive Metastore, and how to undo it.
- Two procedures, two intentions
- The catalog requirement
- Snapshot migration, measured
- In-place migration, measured
- Before you migrate anything
- Migrating in pieces
- After the cutover
- Rolling back a migration
- What migration does not change
- A migration sequence that works
- Verification worth doing
- Run it yourself
- Common misconceptions
- A model worth keeping
- References
- Trademarks
TL;DR
- Neither procedure rewrites data. Both write Iceberg metadata over the Parquet files already on storage.
snapshotimported 6 files andmigrate6 files, and no Parquet file was rewritten.system.snapshotbuilds a parallel Iceberg table over the source’s files and marks itgc.enabled=false, soDROP TABLE ... PURGEon the copy is refused. It is the safe rehearsal.system.migrateconverts in place and keeps the original as<name>_BACKUP_. On this Hive 4 metastore, a table created withoutLOCATIONis moved to the backup’s directory by that rename, which on S3 means every file is copied.migrateneeds Iceberg’sSparkSessionCatalogas Spark’s session catalog;snapshotdoes not.DROP TABLE ... PURGEon a migrated oradd_filestable deletes files the Hive table still points at. The backup then returned 0 rows, silently.
Two procedures, two intentions
Converting a Hive table to Iceberg sounds like it should mean rewriting the data. It does not. The Parquet files a Hive table points at are already the format Iceberg uses; what is missing is the metadata tree: the manifests, the manifest list, the snapshot, the schema with field IDs.
Both procedures build that tree over the existing files. They differ in what happens to the original.
flowchart TB
H[(Hive table<br/>parquet files)] -->|"system.snapshot"| C["new Iceberg table<br/>same files, original untouched"]
H -->|"system.migrate"| M["same name, now Iceberg<br/>original kept as name_BACKUP_"]
system.snapshot is the rehearsal. It creates a second table, under a new
name, whose metadata references the same data files. The original keeps working,
keeps serving queries, and keeps accepting writes. You point a test workload at
the copy and compare.
system.migrate is the cutover. The table keeps its name and becomes an
Iceberg table. Existing readers now get Iceberg semantics whether or not they
were ready for them.
The order in the title is the recommendation: snapshot, validate, then migrate.
Every result below comes from the scripts in Run it yourself,
on a Hive Parquet table of 6,000 orders in three daily dt partitions.
The catalog requirement
Both procedures read the Hive table through Spark’s session catalog. migrate
also replaces it there, so it insists that the session catalog be Iceberg’s
SparkSessionCatalog, which serves Hive tables as before and Iceberg tables
beside them:
spark.sql.catalog.spark_catalog org.apache.iceberg.spark.SparkSessionCatalog
spark.sql.catalog.spark_catalog.type hive
spark.sql.catalog.spark_catalog.uri thrift://hive-metastore:9083
Without it, with only a separate Iceberg catalog configured:
CALL ice.system.migrate('blog_hive_migration.legacy_a')
-> IllegalArgumentException: Cannot migrate a table from a non-Iceberg Spark Session Catalog. Found spark_catalog of class org.apache.spark.sql.execution.datasources.v2.V2SessionCatalog as the source catalog.
CALL ice.system.snapshot('spark_catalog.blog_hive_migration.legacy_a', 'ice.blog_hive_migration.legacy_a_copy')
-> succeeded
CALL spark_catalog.system.migrate('blog_hive_migration.legacy_a')
-> AnalysisException: Cannot use catalog spark_catalog: not a ProcedureCatalog
snapshot only reads the source, so it works from any Iceberg catalog.
migrate has to swap the table in the session catalog itself
(MigrateTableSparkAction.checkSourceCatalog), so configure the session catalog
first; it is the first thing to check when the procedure refuses.
Snapshot migration, measured
CALL spark_catalog.system.snapshot('blog_hive_migration.legacy_a', 'blog_hive_migration.legacy_a_ice');
== the Hive table ==
provider : hive
type in metastore : EXTERNAL
translated : TRUE | external.table.purge: TRUE
serde : org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe
rows : 6000
partitions : 3
files, bytes : (6, 134768)
== CALL spark_catalog.system.snapshot ==
+--------------------+
|imported_files_count|
+--------------------+
| 6|
+--------------------+
copy provider : iceberg
copy rows : 6000
copy snapshots : 1
copy gc.enabled : false
copy 'snapshot' : true
source location : s3a://warehouse/blog_hive_migration.db/legacy_a
copy location : s3a://warehouse/blog_hive_migration.db/legacy_a_ice
copy data files : 6 files, 134768 bytes, 6 of them under the source location
partition spec : Row(spec_id=0, partition=Row(dt='2026-01-05'))
data files under the copy's location: (0, 0)
Both tables report 6,000 rows, and there is only one copy of the data on
storage: the Iceberg table’s six data files are the Hive table’s six files,
byte for byte, and its own location holds no data files at all, only metadata.
The copy has exactly one snapshot, because its history begins at conversion. The
Hive dt partition becomes an identity partition on the same string column.
The copy protects the source from itself. SnapshotTableSparkAction sets
gc.enabled=false and a snapshot=true marker on the new table, and gives it
its own location, refusing one that overlaps the source. So the most dangerous
command is refused outright:
DROP TABLE ... PURGE: Cannot purge table: GC is disabled (deleting files may corrupt other tables)
after DROP TABLE (no PURGE), source rows: 6100
source files, bytes : (8, 140110)
A plain DROP TABLE on the copy removes only its metadata entry; the source
keeps every file. The same setting stops expire_snapshots and
remove_orphan_files from deleting the source’s files through the copy.
What the copy does not do is track the original.
== the two tables diverge ==
source rows after a Hive insert : 6100
copy rows after that insert : 6000
copy rows after writing to the copy: 6050
source rows after that : 6100
the copy's new file lands in : s3a://warehouse/blog_hive_migration.db/legacy_a_ice/data/...
Rows written to the Hive table after the snapshot do not appear in the copy, because the copy’s manifests list the files that existed at conversion. Rows written to the copy land under the copy’s own location and never reach the Hive table. For a table under active write, the copy is a validation target, not a mirror, and the divergence starts immediately. It is a rollback plan only for the moment it was taken.
In-place migration, measured
CALL spark_catalog.system.migrate('blog_hive_migration.legacy_b');
+--------------------+
|migrated_files_count|
+--------------------+
| 6|
+--------------------+
tables now: ['legacy_b_backup_', 'legacy_b']
legacy_b provider : iceberg
legacy_b 'migrated' : true | gc.enabled: (unset, default true)
legacy_b location : s3a://warehouse/blog_hive_migration.db/legacy_b
legacy_b_BACKUP_ provider: hive
legacy_b_BACKUP_ location: s3a://warehouse/blog_hive_migration.db/legacy_b_backup_
rows, Iceberg / backup : 6000 / 6000
Iceberg data files : 6, 0 of them still in the Hive dt=... directories
snapshots : 1
partition spec : Row(dt='2026-01-05')
MigrateTableSparkAction works in three steps: rename the Hive table to
<name>_BACKUP_, stage a new Iceberg table under the original name at the
original location, and import the backup’s files into it. If the import fails,
it renames the backup back. The new table is marked migrated=true, and, unlike a
snapshot copy, it keeps the default gc.enabled=true, because it owns its data
from now on.
On this metastore the rename moves the data. The Hive 4 metastore stores a
table created without LOCATION as EXTERNAL, with
TRANSLATED_TO_EXTERNAL=TRUE and external.table.purge=TRUE, and renaming such a
table moves its directory. So the backup moved to legacy_b_backup_, all six
Iceberg data files now point there, and the Iceberg table’s own location holds
only metadata. On S3 a directory rename copies and deletes every object, so the
“no data is copied” promise does not hold for these tables: the procedure writes
no new Parquet, but the storage layer copies every file once.
A table created with an explicit LOCATION behaves the way the documentation
suggests:
== a table created with an explicit LOCATION ==
legacy_e type : EXTERNAL
legacy_e location : s3a://warehouse/blog_hive_migration_ext/legacy_e
legacy_e_BACKUP_ location: s3a://warehouse/blog_hive_migration_ext/legacy_e
Iceberg files still under the original directory: 6 of 6
The backup keeps the original directory, the Iceberg table shares it, and nothing
moves. Check a table’s Type and Table Properties in DESCRIBE TABLE
EXTENDED before migrating: TRANSLATED_TO_EXTERNAL=TRUE means the rename will
move the data.
migrate takes three options besides properties: drop_backup => true removes
the backup once the import succeeds, backup_table_name names it, and
parallelism sets how many threads read file footers.
Reading the migrated table from Hive and Trino
A migrated table is registered in the metastore as table_type=ICEBERG, but
without a Hive storage handler unless you ask for one, and Hive itself then
cannot read it. Set engine.hive.enabled=true (default false in
TableProperties) and Iceberg registers HiveIcebergStorageHandler, which Hive 4
ships with. Pass it to migrate directly:
CALL spark_catalog.system.migrate(
table => 'blog_hive_migration.legacy_c',
properties => map('engine.hive.enabled', 'true'),
drop_backup => true);
The checks read three tables from HiveServer2: the Hive table legacy_e,
legacy_c migrated with the property, and legacy_d migrated without it. Then
Trino reads legacy_c through its iceberg and its hive catalog:
hive: legacy_e
6000
hive: legacy_c
6500
hive: legacy_d
Cannot create an instance of InputFormat class org.apache.hadoop.mapred.FileInputFormat
"6500"
Query 20260929_211918_00050_akf4b failed: Cannot query Iceberg table 'blog_hive_migration.legacy_c'
Without the property Hive fails at planning, before it reads a file. Trino’s
iceberg catalog reads the migrated table; its hive catalog refuses every
Iceberg table, so point Trino readers at the iceberg catalog after the cutover.
The storage handler has a cost on the Spark side: Spark’s built-in Hive client
cannot load it, so a DROP DATABASE ... CASCADE through Spark fails on such a
table with Error in loading storage handler.org.apache.iceberg.mr.hive.HiveIcebergStorageHandler.
Drop those tables through Iceberg’s session catalog first, as the clean-up below
does.
Before you migrate anything
Migration converts metadata, so the things that go wrong are things the old table tolerated and the new one will not. Four checks, each cheap.
Schema compatibility. Hive is permissive about types that Iceberg is strict
about, and a column typed string in the metastore but holding numbers in the
files is a problem you want to find now:
DESCRIBE TABLE EXTENDED blog_hive_migration.legacy_a;
SELECT * FROM blog_hive_migration.legacy_a LIMIT 5;
File formats in the table. Both procedures accept tables whose provider is
parquet, orc, avro or hive (BaseTableCreationSparkAction), and the import
reads each partition’s format from that partition’s own serde, so a table whose
partitions were written in different formats converts partition by partition.
Metrics come from Parquet and ORC footers; Avro files get only limited metrics
(TableMigrationUtil). Count what is actually there:
docker exec mc /usr/bin/mc ls --recursive minio/warehouse/blog_hive_migration.db/legacy_a/ \
| awk '{print $NF}' | sed 's/.*\.//' | sort | uniq -c
1 _SUCCESS
8 parquet
Partition column types. Hive partition values are strings in the metastore, and the migrated table keeps them as a string identity partition, as the specs above show. A partition column that is logically a date stays a string; typing it properly is a post-migration change, not something migration does.
File count. The import reads every Parquet or ORC footer for statistics, so time scales with file count rather than data volume:
docker exec mc /usr/bin/mc ls --recursive minio/warehouse/blog_hive_migration.db/legacy_a/ | wc -l
A million small files is a long job and a strong argument for compacting after migration, not before: compaction on the Iceberg side is a single procedure.
Migrating in pieces
For a table too large or too important to convert in one step, add_files moves
partitions into a new Iceberg table incrementally, leaving the Hive table serving
throughout:
CREATE TABLE blog_hive_migration.sales (
order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2),
region STRING, event_ts TIMESTAMP, dt STRING)
USING iceberg PARTITIONED BY (dt);
CALL spark_catalog.system.add_files(
table => 'blog_hive_migration.sales',
source_table => 'blog_hive_migration.legacy_sales',
partition_filter => map('dt', '2026-01-06'));
+-----------------+-----------------------+
|added_files_count|changed_partition_count|
+-----------------+-----------------------+
| 2| NULL|
+-----------------+-----------------------+
Iceberg : Row(count(1)=2000, sum(amount)=Decimal('504951.62'))
Hive : Row(count(1)=2000, sum(amount)=Decimal('504951.62'))
files shared: 2 of 2
Loop over partitions, verify each one as you go with the same count and sum on both sides, and cut readers over only when the new table is complete:
SELECT count(*), sum(amount) FROM blog_hive_migration.sales WHERE dt = '2026-01-06';
SELECT count(*), sum(amount) FROM blog_hive_migration.legacy_sales WHERE dt = '2026-01-06';
The data files are shared, so this costs metadata and not storage. Adding the
same partition twice is refused, because check_duplicate_files defaults to true:
add_files twice: Cannot complete import because data files to be imported already exist within the target table: s3a://warehouse/blog_hive_migration.db/legacy_sales/dt=2026-01-06/part-00000-a5e111a0-3ab8-4d99-8a19-5656c50eb142.c000.snappy.parquet,
The caution with add_files: it does not check that the files match the
declared schema. A partition whose files have an extra column, or a differently
typed one, is added and fails at read time. And the new table owns the adopted
files like any other: it keeps gc.enabled=true, so dropping it with PURGE
deletes files the Hive table still needs:
rows, Iceberg / Hive: 6000 / 6000
gc.enabled on the Iceberg table: (unset, default true)
== dropping the Iceberg table with PURGE ==
Hive rows afterwards: org.apache.spark.SparkFileNotFoundException: No such file or directory: s3a://warehouse/blog_hive_migration.db/legacy_sales/dt=2026-01-05/part-00000-a4ea8a15-5362-4746-93a4-53e1fb950abc.c000.snappy.parquet
Which missing file the error names first varies between runs; that the Hive
table can no longer be read does not. Until the Hive table is retired, set
gc.enabled=false on an add_files target, or never drop it with PURGE.
After the cutover
Migration gives you Iceberg metadata over a Hive-shaped table. The things that make Iceberg fast are separate jobs, and doing them after the cutover keeps each step diagnosable.
-- 1. the layout properties the table should have had
ALTER TABLE blog_hive_migration.legacy_c SET TBLPROPERTIES (
'write.target-file-size-bytes' = '268435456',
'write.object-storage.enabled' = 'true',
'write.parquet.compression-codec' = 'zstd',
'write.metadata.delete-after-commit.enabled' = 'true');
-- 2. a sort order, if queries filter on something other than the partition
ALTER TABLE blog_hive_migration.legacy_c WRITE ORDERED BY region, dt;
-- 3. then a compaction that applies both
CALL spark_catalog.system.rewrite_data_files(
table => 'blog_hive_migration.legacy_c',
options => map('rewrite-all','true','target-file-size-bytes','268435456'));
-- 4. and only now, a transform-based partition layout for new data
ALTER TABLE blog_hive_migration.legacy_c REPLACE PARTITION FIELD dt WITH days(event_ts);
Step 3 needs a threshold that matches the table. With
map('min-input-files','5', ...) it rewrote nothing, because each of the three
partitions held two files, below five. With rewrite-all it rewrote all six into
three:
rewrite with the post's options:
+--------------------------+----------------------+
|rewritten_data_files_count|added_data_files_count|
+--------------------------+----------------------+
| 0| 0|
+--------------------------+----------------------+
rewrite with rewrite-all:
+--------------------------+----------------------+
|rewritten_data_files_count|added_data_files_count|
+--------------------------+----------------------+
| 6| 3|
+--------------------------+----------------------+
+-------+-----+----+
|spec_id|files|rows|
+-------+-----+----+
| 0| 3|6000|
| 1| 1| 500|
+-------+-----+----+
rows: 6500
Step 4 is worth resisting until the rest is stable. Hidden partitioning on a
migrated table applies to new data: the next 500 rows landed under spec 1
(days(event_ts)) while the existing files kept spec 0, and a table carrying
three specs during a migration is harder to reason about than one carrying two.
Rolling back a migration
In-place migrate leaves the original table behind as <name>_BACKUP_, which
the metastore stores in lower case. That is the undo, and it is worth confirming
it exists before you need it:
SHOW TABLES IN blog_hive_migration LIKE '*backup*';
To revert, drop the Iceberg table without purging, then rename the backup back. On this metastore the rename fails at first:
== rolling back ==
backup rows after DROP TABLE: 6000
rename the backup back: org.apache.hadoop.hive.ql.metadata.HiveException: Unable to alter table. New location for this table hive.blog_hive_migration.legacy_b already exists : s3a://warehouse/blog_hive_migration.db/legacy_b
after the rename, legacy_b provider: hive | rows: 6000
after the rename, legacy_b location: s3a://warehouse/blog_hive_migration.db/legacy_b
tables now: ['legacy_b']
The dropped Iceberg table left its metadata/ folder at the original location,
and renaming a translated table moves its directory there. The data files live
under the backup’s directory, so the leftover folder is safe to delete; after
that, the rename succeeded and moved the data back:
DROP TABLE blog_hive_migration.legacy_b; -- metadata only; keeps the data files
-- delete s3a://warehouse/blog_hive_migration.db/legacy_b/ (only Iceberg metadata is left there)
ALTER TABLE blog_hive_migration.legacy_b_BACKUP_ RENAME TO blog_hive_migration.legacy_b;
For a table created with an explicit LOCATION, nothing moved in the first place,
and the same DROP TABLE plus rename worked directly: legacy_e was back to
hive with 6,000 rows.
DROP TABLE ... PURGE deletes the data files that both the Iceberg table and
the backup point at, which turns a reversible migration into a restore from
backup. Measured, it fails silently:
== migrate again, then the expensive mistake ==
backup rows after DROP TABLE ... PURGE: 0
backup files still listed: 0
The backup still exists and still answers queries, with zero rows. This is the single most expensive mistake available in this whole procedure. Keep the backup until you are confident; it costs a metastore entry and nothing else.
What migration does not change
Worth being explicit, because expectations here cause post-cutover surprises.
File layout stays as it was, but not always where it was. The Hive
dt=2026-01-05 directories keep their files, and Iceberg records them as
partition values in its metadata; it does not reorganise storage. For a table
the metastore translated to EXTERNAL, those directories now sit under the
backup’s name. You get Iceberg’s metadata benefits immediately and its layout
benefits only after you rewrite data.
Hidden partitioning does not appear retroactively. The converted table has an identity partition matching the Hive column, as a string. Transform-based partitioning is a later change, applied to new data, with old files keeping the original spec.
File sizes stay as they were. If the Hive table had a small-file problem, the
Iceberg table has the same small-file problem, now with accurate statistics that
let you see it. rewrite_data_files is the fix, and it is a separate,
data-moving job.
Statistics come from the files. Iceberg records column bounds by reading Parquet and ORC footers during conversion. That is what makes the conversion fast, and what makes conversion of a table with millions of files not fast.
A migration sequence that works
- Check the catalog and the table type.
spark_catalogmust beSparkSessionCatalog, andDESCRIBE TABLE EXTENDEDtells you whether the rename will move the data. - Snapshot into a copy.
system.snapshot('db.sales', 'db.sales_iceberg'). The original keeps serving. - Validate. Row counts, aggregate checksums per partition, and the queries that matter, run against both. Compare results, not just counts: a schema mismatch shows up in values long before it shows up in a count.
- Test the write path. Run your actual writers against the copy, and your Hive readers with
engine.hive.enabledset. This is where engines that cannot handle Iceberg reveal themselves, and it is much cheaper to learn now. - Freeze writes to the original, briefly.
- Migrate in place.
system.migrate('db.sales'), which preserves the table name so downstream references keep resolving. - Verify, then keep the backup until you are confident. The
_BACKUP_table is the undo. - Then improve the layout. Compaction, sort order, partition spec changes, all of it after the cutover, none of it during.
Do not combine steps 6 and 8. A cutover that also reorganises storage cannot be reasoned about when something looks wrong afterwards, and it turns a metadata operation into a data-movement operation.
Verification worth doing
Counts agreeing is necessary and not sufficient. Three checks catch most real problems, and all three passed on the snapshot copy:
== verifying the copy against the source ==
per partition, source: [('2026-01-05', 2000), ('2026-01-06', 2000), ('2026-01-07', 2000)]
per partition, copy : [('2026-01-05', 2000), ('2026-01-06', 2000), ('2026-01-07', 2000)]
checksums, source : (Decimal('1511812.16'), Decimal('5.12'), Decimal('499.87'))
checksums, copy : (Decimal('1511812.16'), Decimal('5.12'), Decimal('499.87'))
file accounting, copy: (6, 134768) | source directory: (6, 134768)
Per-partition counts catch a partition that failed to map:
SELECT dt, count(*) FROM blog_hive_migration.legacy_a_ice GROUP BY dt ORDER BY dt;
Aggregate checksums on numeric columns catch type-mapping errors that preserve row count while changing values:
SELECT sum(amount), min(amount), max(amount) FROM blog_hive_migration.legacy_a_ice;
File accounting confirms nothing was copied:
SELECT count(*), sum(file_size_in_bytes) FROM blog_hive_migration.legacy_a_ice.files;
Compare that byte total against the original directory. They matched, 6 files and 134,768 bytes, because the files are the same files.
Run it yourself
Everything above ran on the local lakehouse stack described in
Run every demo on this blog locally,
with Spark 3.5.9, Iceberg 1.11.0, its Hive 4.0.0 metastore and
HiveServer2, and Trino 483. Trino is in the all profile, so start that one
if you want the Trino check:
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
Add PROFILE=all to the start command for Trino. The scripts create everything
in a new Hive database, blog_hive_migration, and run in this order, each with the
command in its docstring.
snapshot_migration.py
"""Snapshot a Hive Parquet table into Iceberg and see what the copy shares.
Run inside the datalake-playground stack:
docker cp snapshot_migration.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/snapshot_migration.py'
"""
import random
from datetime import datetime, timedelta
from decimal import Decimal
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("hive-snapshot-migration")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
# The session catalog must be Iceberg's SparkSessionCatalog for snapshot and
# migrate: it serves Hive tables as before and Iceberg tables next to them.
.config("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog")
.config("spark.sql.catalog.spark_catalog.type", "hive")
.config("spark.sql.catalog.spark_catalog.uri", "thrift://hive-metastore:9083")
.config("spark.hadoop.hive.exec.dynamic.partition.mode", "nonstrict")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.enableHiveSupport()
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
DB = "blog_hive_migration"
def one(q):
return sql(q).collect()[0][0]
def prop(table, key):
rows = sql(f"SHOW TBLPROPERTIES {table}").collect()
return {r[0]: r[1] for r in rows}.get(key)
def describe(table, key):
return (sql(f"DESCRIBE TABLE EXTENDED {table}")
.where(f"col_name = '{key}'").collect()[0][1])
def storage(path):
"""Parquet data files and their bytes under a location, via the Hadoop FileSystem."""
jvm = spark._jvm
p = jvm.org.apache.hadoop.fs.Path(path)
it = p.getFileSystem(spark._jsc.hadoopConfiguration()).listFiles(p, True)
files, size = 0, 0
while it.hasNext():
f = it.next()
if f.getPath().getName().endswith(".parquet") or f.getPath().getName().startswith("part-"):
files += 1
size += f.getLen()
return files, size
def remove(path):
"""Delete a directory. The Hive 4 metastore stores tables created without
LOCATION as EXTERNAL, so DROP TABLE leaves their files behind."""
jvm = spark._jvm
p = jvm.org.apache.hadoop.fs.Path(path)
p.getFileSystem(spark._jsc.hadoopConfiguration()).delete(p, True)
def orders(n, day):
rng = random.Random(day)
start = datetime.strptime(day, "%Y-%m-%d")
return [(i, rng.randint(1, 900), Decimal(str(round(rng.uniform(5, 500), 2))),
rng.choice(["north", "south", "east", "west"]),
start + timedelta(seconds=rng.randint(0, 86_399)), day)
for i in range(n)]
SCHEMA = ("order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2), "
"region STRING, event_ts TIMESTAMP, dt STRING")
sql(f"CREATE DATABASE IF NOT EXISTS {DB}")
DB_LOC = sql(f"DESCRIBE DATABASE {DB}").where("info_name = 'Location'").collect()[0][1]
for t in ("legacy_a_ice", "legacy_a"): # make the script re-runnable
sql(f"DROP TABLE IF EXISTS {DB}.{t}")
remove(f"{DB_LOC}/{t}")
sql(f"""CREATE TABLE {DB}.legacy_a (
order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2),
region STRING, event_ts TIMESTAMP)
PARTITIONED BY (dt STRING)
STORED AS PARQUET""")
for day in ("2026-01-05", "2026-01-06", "2026-01-07"):
spark.createDataFrame(orders(2_000, day), SCHEMA).createOrReplaceTempView("batch")
sql(f"INSERT INTO {DB}.legacy_a PARTITION (dt) SELECT * FROM batch")
src_loc = describe(f"{DB}.legacy_a", "Location")
print("== the Hive table ==")
print("provider :", describe(f"{DB}.legacy_a", "Provider"))
print("type in metastore :", describe(f"{DB}.legacy_a", "Type"))
print("translated :", prop(f"{DB}.legacy_a", "TRANSLATED_TO_EXTERNAL"),
"| external.table.purge:", prop(f"{DB}.legacy_a", "external.table.purge"))
print("serde :", describe(f"{DB}.legacy_a", "Serde Library"))
print("rows :", one(f"SELECT count(*) FROM {DB}.legacy_a"))
print("partitions :", sql(f"SHOW PARTITIONS {DB}.legacy_a").count())
print("files, bytes :", storage(src_loc))
print("\n== CALL spark_catalog.system.snapshot ==")
sql(f"CALL spark_catalog.system.snapshot('{DB}.legacy_a', '{DB}.legacy_a_ice')").show()
copy_loc = describe(f"{DB}.legacy_a_ice", "Location")
print("copy provider :", describe(f"{DB}.legacy_a_ice", "Provider"))
print("copy rows :", one(f"SELECT count(*) FROM {DB}.legacy_a_ice"))
print("copy snapshots :", one(f"SELECT count(*) FROM {DB}.legacy_a_ice.snapshots"))
print("copy gc.enabled :", prop(f"{DB}.legacy_a_ice", "gc.enabled"))
print("copy 'snapshot' :", prop(f"{DB}.legacy_a_ice", "snapshot"))
print("source location :", src_loc)
print("copy location :", copy_loc)
f = sql(f"SELECT count(*) AS n, sum(file_size_in_bytes) AS bytes, "
f"sum(CASE WHEN file_path LIKE '{src_loc}/%' THEN 1 ELSE 0 END) AS in_source "
f"FROM {DB}.legacy_a_ice.files").collect()[0]
print(f"copy data files : {f.n} files, {f.bytes} bytes, {f.in_source} of them under the source location")
print("partition spec :", sql(f"SELECT spec_id, partition FROM {DB}.legacy_a_ice.files LIMIT 1").collect()[0])
print("data files under the copy's location:", storage(copy_loc))
print("\n== verifying the copy against the source ==")
def per_partition(table):
return [tuple(r) for r in sql(f"SELECT dt, count(*) AS n FROM {table} "
f"GROUP BY dt ORDER BY dt").collect()]
def checksum(table):
return tuple(sql(f"SELECT sum(amount), min(amount), max(amount) FROM {table}").collect()[0])
print("per partition, source:", per_partition(f"{DB}.legacy_a"))
print("per partition, copy :", per_partition(f"{DB}.legacy_a_ice"))
print("checksums, source :", checksum(f"{DB}.legacy_a"))
print("checksums, copy :", checksum(f"{DB}.legacy_a_ice"))
print("file accounting, copy:", tuple(sql(f"SELECT count(*), sum(file_size_in_bytes) "
f"FROM {DB}.legacy_a_ice.files").collect()[0]),
"| source directory:", storage(src_loc))
print("\n== the two tables diverge ==")
spark.createDataFrame(orders(100, "2026-01-08"), SCHEMA).createOrReplaceTempView("late")
sql(f"INSERT INTO {DB}.legacy_a PARTITION (dt) SELECT * FROM late")
print("source rows after a Hive insert :", one(f"SELECT count(*) FROM {DB}.legacy_a"))
print("copy rows after that insert :", one(f"SELECT count(*) FROM {DB}.legacy_a_ice"))
sql(f"INSERT INTO {DB}.legacy_a_ice SELECT * FROM late WHERE order_id < 50")
print("copy rows after writing to the copy:", one(f"SELECT count(*) FROM {DB}.legacy_a_ice"))
print("source rows after that :", one(f"SELECT count(*) FROM {DB}.legacy_a"))
new_file = sql(f"SELECT file_path FROM {DB}.legacy_a_ice.files "
f"WHERE file_path NOT LIKE '{src_loc}/%'").collect()[0][0]
print("the copy's new file lands in :", new_file.rsplit("/", 2)[0] + "/...")
print("\n== dropping the copy ==")
try:
sql(f"DROP TABLE {DB}.legacy_a_ice PURGE")
print("DROP TABLE ... PURGE: succeeded")
except Exception as e:
msg = e.java_exception.getMessage() if hasattr(e, "java_exception") else str(e)
print("DROP TABLE ... PURGE:", msg.splitlines()[0][:160])
sql(f"DROP TABLE {DB}.legacy_a_ice")
print("after DROP TABLE (no PURGE), source rows:", one(f"SELECT count(*) FROM {DB}.legacy_a"))
print("source files, bytes :", storage(src_loc))
spark.stop()
migrate_needs_session_catalog.py
"""What migrate and snapshot say when Spark's session catalog is not Iceberg's.
Run inside the datalake-playground stack, after snapshot_migration.py:
docker cp migrate_needs_session_catalog.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/migrate_needs_session_catalog.py'
"""
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("migrate-needs-session-catalog")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
# An Iceberg catalog on the same metastore, but spark_catalog left as Spark's own.
.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.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.enableHiveSupport()
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
spark.sql("DROP TABLE IF EXISTS ice.blog_hive_migration.legacy_a_copy") # re-runnable
for q in ("CALL ice.system.migrate('blog_hive_migration.legacy_a')",
"CALL ice.system.snapshot('spark_catalog.blog_hive_migration.legacy_a', "
"'ice.blog_hive_migration.legacy_a_copy')",
"CALL spark_catalog.system.migrate('blog_hive_migration.legacy_a')"):
try:
spark.sql(q).collect()
print(f"{q}\n -> succeeded")
except Exception as e:
msg = e.java_exception.getMessage() if hasattr(e, "java_exception") else str(e)
print(f"{q}\n -> {type(e).__name__}: {msg.splitlines()[0][:200]}")
spark.stop()
migrate_in_place.py
"""Migrate a Hive Parquet table to Iceberg in place, roll it back, and see what PURGE does.
Run inside the datalake-playground stack:
docker cp migrate_in_place.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/migrate_in_place.py'
"""
import random
from datetime import datetime, timedelta
from decimal import Decimal
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("hive-migrate-in-place")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog")
.config("spark.sql.catalog.spark_catalog.type", "hive")
.config("spark.sql.catalog.spark_catalog.uri", "thrift://hive-metastore:9083")
.config("spark.hadoop.hive.exec.dynamic.partition.mode", "nonstrict")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.enableHiveSupport()
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
DB = "blog_hive_migration"
def one(q):
return sql(q).collect()[0][0]
def props(table):
return {r[0]: r[1] for r in sql(f"SHOW TBLPROPERTIES {table}").collect()}
def describe(table, key):
return (sql(f"DESCRIBE TABLE EXTENDED {table}")
.where(f"col_name = '{key}'").collect()[0][1])
def remove(path):
jvm = spark._jvm
p = jvm.org.apache.hadoop.fs.Path(path)
p.getFileSystem(spark._jsc.hadoopConfiguration()).delete(p, True)
def attempt(label, q):
try:
result = sql(q).collect()
print(f"{label}: {result[0][0] if result else 'ok'}")
except Exception as e:
msg = e.java_exception.getMessage() if hasattr(e, "java_exception") else str(e)
# show the root cause when Spark wraps it in a job failure
root = [l for l in msg.splitlines() if "FileNotFoundException" in l]
line = root[0].split("): ", 1)[-1] if root else msg.splitlines()[0]
print(f"{label}: {line[:230]}")
def orders(n, day):
rng = random.Random(day)
start = datetime.strptime(day, "%Y-%m-%d")
return [(i, rng.randint(1, 900), Decimal(str(round(rng.uniform(5, 500), 2))),
rng.choice(["north", "south", "east", "west"]),
start + timedelta(seconds=rng.randint(0, 86_399)), day)
for i in range(n)]
SCHEMA = ("order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2), "
"region STRING, event_ts TIMESTAMP, dt STRING")
def hive_table(name, location=None):
"""A Hive Parquet table with 6,000 orders over three daily partitions."""
where = f"LOCATION '{location}'" if location else ""
kind = "EXTERNAL TABLE" if location else "TABLE"
sql(f"""CREATE {kind} {DB}.{name} (
order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2),
region STRING, event_ts TIMESTAMP)
PARTITIONED BY (dt STRING)
STORED AS PARQUET {where}""")
for day in ("2026-01-05", "2026-01-06", "2026-01-07"):
spark.createDataFrame(orders(2_000, day), SCHEMA).createOrReplaceTempView("batch")
sql(f"INSERT INTO {DB}.{name} PARTITION (dt) SELECT * FROM batch")
sql(f"CREATE DATABASE IF NOT EXISTS {DB}")
DB_LOC = sql(f"DESCRIBE DATABASE {DB}").where("info_name = 'Location'").collect()[0][1]
EXT_LOC = "s3a://warehouse/blog_hive_migration_ext/legacy_e"
for t in ("legacy_b", "legacy_b_BACKUP_", "legacy_c", "legacy_c_BACKUP_",
"legacy_d", "legacy_e", "legacy_e_BACKUP_"):
sql(f"DROP TABLE IF EXISTS {DB}.{t}")
remove(f"{DB_LOC}/{t.lower()}")
remove(EXT_LOC)
hive_table("legacy_b")
loc = describe(f"{DB}.legacy_b", "Location")
print("== CALL spark_catalog.system.migrate ==")
sql(f"CALL spark_catalog.system.migrate('{DB}.legacy_b')").show()
print("tables now:", [r.tableName for r in sql(f"SHOW TABLES IN {DB} LIKE 'legacy_b*'").collect()])
p = props(f"{DB}.legacy_b")
print("legacy_b provider :", describe(f"{DB}.legacy_b", "Provider"))
print("legacy_b 'migrated' :", p.get("migrated"), "| gc.enabled:", p.get("gc.enabled", "(unset, default true)"))
print("legacy_b location :", describe(f"{DB}.legacy_b", "Location"))
print("legacy_b_BACKUP_ provider:", describe(f"{DB}.legacy_b_BACKUP_", "Provider"))
print("legacy_b_BACKUP_ location:", describe(f"{DB}.legacy_b_BACKUP_", "Location"))
print("rows, Iceberg / backup :", one(f"SELECT count(*) FROM {DB}.legacy_b"), "/",
one(f"SELECT count(*) FROM {DB}.legacy_b_BACKUP_"))
f = sql(f"SELECT count(*) AS n, sum(CASE WHEN file_path LIKE '{loc}/dt=%' THEN 1 ELSE 0 END) AS hive_dirs "
f"FROM {DB}.legacy_b.files").collect()[0]
print(f"Iceberg data files : {f.n}, {f.hive_dirs} of them still in the Hive dt=... directories")
print("snapshots :", one(f"SELECT count(*) FROM {DB}.legacy_b.snapshots"))
print("partition spec :", sql(f"SELECT partition FROM {DB}.legacy_b.files LIMIT 1").collect()[0][0])
print("\n== rolling back ==")
sql(f"DROP TABLE {DB}.legacy_b") # Iceberg drop without PURGE keeps the data files
print("backup rows after DROP TABLE:", one(f"SELECT count(*) FROM {DB}.legacy_b_BACKUP_"))
attempt("rename the backup back", f"ALTER TABLE {DB}.legacy_b_BACKUP_ RENAME TO {DB}.legacy_b")
# The dropped Iceberg table left its metadata/ folder at the old location. The
# data files live under the backup's directory, so that folder is safe to remove.
remove(loc)
sql(f"ALTER TABLE {DB}.legacy_b_BACKUP_ RENAME TO {DB}.legacy_b")
print("after the rename, legacy_b provider:", describe(f"{DB}.legacy_b", "Provider"),
"| rows:", one(f"SELECT count(*) FROM {DB}.legacy_b"))
print("after the rename, legacy_b location:", describe(f"{DB}.legacy_b", "Location"))
print("tables now:", [r.tableName for r in sql(f"SHOW TABLES IN {DB} LIKE 'legacy_b*'").collect()])
print("\n== migrate again, then the expensive mistake ==")
sql(f"CALL spark_catalog.system.migrate('{DB}.legacy_b')").collect()
sql(f"DROP TABLE {DB}.legacy_b PURGE")
attempt("backup rows after DROP TABLE ... PURGE", f"SELECT count(*) FROM {DB}.legacy_b_BACKUP_")
attempt("backup files still listed", f"SELECT count(*) FROM (SELECT DISTINCT input_file_name() FROM {DB}.legacy_b_BACKUP_)")
print("\n== a table created with an explicit LOCATION ==")
hive_table("legacy_e", EXT_LOC)
print("legacy_e type :", describe(f"{DB}.legacy_e", "Type"))
sql(f"CALL spark_catalog.system.migrate('{DB}.legacy_e')").collect()
print("legacy_e location :", describe(f"{DB}.legacy_e", "Location"))
print("legacy_e_BACKUP_ location:", describe(f"{DB}.legacy_e_BACKUP_", "Location"))
print("Iceberg files still under the original directory:",
one(f"SELECT count(*) FROM {DB}.legacy_e.files WHERE file_path LIKE '{EXT_LOC}/dt=%'"), "of",
one(f"SELECT count(*) FROM {DB}.legacy_e.files"))
sql(f"DROP TABLE {DB}.legacy_e")
attempt("rename legacy_e's backup back", f"ALTER TABLE {DB}.legacy_e_BACKUP_ RENAME TO {DB}.legacy_e")
print("legacy_e after rollback:", describe(f"{DB}.legacy_e", "Provider"),
"| rows:", one(f"SELECT count(*) FROM {DB}.legacy_e"))
print("\n== migrate without engine.hive.enabled, for the Hive read check ==")
hive_table("legacy_d")
sql(f"CALL spark_catalog.system.migrate(table => '{DB}.legacy_d', drop_backup => true)").collect()
print("legacy_d provider:", describe(f"{DB}.legacy_d", "Provider"),
"| engine.hive.enabled:", props(f"{DB}.legacy_d").get("engine.hive.enabled", "(unset, default false)"))
print("\n== migrate with drop_backup, then improve the layout ==")
hive_table("legacy_c")
# engine.hive.enabled registers Hive's Iceberg storage handler, so Hive can still read it
sql(f"""CALL spark_catalog.system.migrate(
table => '{DB}.legacy_c',
properties => map('engine.hive.enabled', 'true'),
drop_backup => true)""").show()
print("tables now:", [r.tableName for r in sql(f"SHOW TABLES IN {DB} LIKE 'legacy_c*'").collect()])
sql(f"""ALTER TABLE {DB}.legacy_c SET TBLPROPERTIES (
'write.target-file-size-bytes' = '268435456',
'write.object-storage.enabled' = 'true',
'write.parquet.compression-codec' = 'zstd',
'write.metadata.delete-after-commit.enabled' = 'true')""")
sql(f"ALTER TABLE {DB}.legacy_c WRITE ORDERED BY region, dt")
print("rewrite with the post's options:")
sql(f"""CALL spark_catalog.system.rewrite_data_files(
table => '{DB}.legacy_c',
options => map('min-input-files','5','target-file-size-bytes','268435456'))""") \
.select("rewritten_data_files_count", "added_data_files_count").show()
print("rewrite with rewrite-all:")
sql(f"""CALL spark_catalog.system.rewrite_data_files(
table => '{DB}.legacy_c',
options => map('rewrite-all','true','target-file-size-bytes','268435456'))""") \
.select("rewritten_data_files_count", "added_data_files_count").show()
sql(f"ALTER TABLE {DB}.legacy_c REPLACE PARTITION FIELD dt WITH days(event_ts)")
spark.createDataFrame(orders(500, "2026-01-08"), SCHEMA).createOrReplaceTempView("new_day")
sql(f"INSERT INTO {DB}.legacy_c SELECT * FROM new_day")
sql(f"SELECT spec_id, count(*) AS files, sum(record_count) AS rows "
f"FROM {DB}.legacy_c.files GROUP BY spec_id ORDER BY spec_id").show()
print("rows:", one(f"SELECT count(*) FROM {DB}.legacy_c"))
spark.stop()
add_files_in_pieces.py
"""Move a Hive table into Iceberg one partition at a time with add_files.
Run inside the datalake-playground stack:
docker cp add_files_in_pieces.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/add_files_in_pieces.py'
"""
import random
from datetime import datetime, timedelta
from decimal import Decimal
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("hive-add-files")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog")
.config("spark.sql.catalog.spark_catalog.type", "hive")
.config("spark.sql.catalog.spark_catalog.uri", "thrift://hive-metastore:9083")
.config("spark.hadoop.hive.exec.dynamic.partition.mode", "nonstrict")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.enableHiveSupport()
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
DB = "blog_hive_migration"
def one(q):
return sql(q).collect()[0][0]
def remove(path):
jvm = spark._jvm
p = jvm.org.apache.hadoop.fs.Path(path)
p.getFileSystem(spark._jsc.hadoopConfiguration()).delete(p, True)
def attempt(label, q):
try:
result = sql(q).collect()
print(f"{label}: {result[0][0] if result else 'ok'}")
except Exception as e:
msg = e.java_exception.getMessage() if hasattr(e, "java_exception") else str(e)
# show the root cause when Spark wraps it in a job failure
root = [l for l in msg.splitlines() if "FileNotFoundException" in l]
line = root[0].split("): ", 1)[-1] if root else msg.splitlines()[0]
print(f"{label}: {line[:230]}")
def orders(n, day):
rng = random.Random(day)
start = datetime.strptime(day, "%Y-%m-%d")
return [(i, rng.randint(1, 900), Decimal(str(round(rng.uniform(5, 500), 2))),
rng.choice(["north", "south", "east", "west"]),
start + timedelta(seconds=rng.randint(0, 86_399)), day)
for i in range(n)]
SCHEMA = ("order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2), "
"region STRING, event_ts TIMESTAMP, dt STRING")
sql(f"CREATE DATABASE IF NOT EXISTS {DB}")
DB_LOC = sql(f"DESCRIBE DATABASE {DB}").where("info_name = 'Location'").collect()[0][1]
for t in ("sales", "legacy_sales"):
sql(f"DROP TABLE IF EXISTS {DB}.{t}")
remove(f"{DB_LOC}/{t}")
sql(f"""CREATE TABLE {DB}.legacy_sales (
order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2),
region STRING, event_ts TIMESTAMP)
PARTITIONED BY (dt STRING) STORED AS PARQUET""")
for day in ("2026-01-05", "2026-01-06", "2026-01-07"):
spark.createDataFrame(orders(2_000, day), SCHEMA).createOrReplaceTempView("batch")
sql(f"INSERT INTO {DB}.legacy_sales PARTITION (dt) SELECT * FROM batch")
sql(f"""CREATE TABLE {DB}.sales (
order_id BIGINT, customer_id BIGINT, amount DECIMAL(10,2),
region STRING, event_ts TIMESTAMP, dt STRING)
USING iceberg PARTITIONED BY (dt)""")
print("== add one partition ==")
sql(f"""CALL spark_catalog.system.add_files(
table => '{DB}.sales',
source_table => '{DB}.legacy_sales',
partition_filter => map('dt', '2026-01-06'))""").show()
print("Iceberg :", sql(f"SELECT count(*), sum(amount) FROM {DB}.sales WHERE dt = '2026-01-06'").collect()[0])
print("Hive :", sql(f"SELECT count(*), sum(amount) FROM {DB}.legacy_sales WHERE dt = '2026-01-06'").collect()[0])
print("files shared:", one(f"SELECT count(*) FROM {DB}.sales.files WHERE file_path LIKE '{DB_LOC}/legacy_sales/%'"),
"of", one(f"SELECT count(*) FROM {DB}.sales.files"))
print("\n== the same partition again ==")
attempt("add_files twice", f"""CALL spark_catalog.system.add_files(
table => '{DB}.sales', source_table => '{DB}.legacy_sales',
partition_filter => map('dt', '2026-01-06'))""")
print("\n== the rest, one partition at a time ==")
for day in ("2026-01-05", "2026-01-07"):
r = sql(f"""CALL spark_catalog.system.add_files(
table => '{DB}.sales', source_table => '{DB}.legacy_sales',
partition_filter => map('dt', '{day}'))""").collect()[0]
print(f"{day}: added {r.added_files_count} files")
print("rows, Iceberg / Hive:", one(f"SELECT count(*) FROM {DB}.sales"), "/",
one(f"SELECT count(*) FROM {DB}.legacy_sales"))
print("gc.enabled on the Iceberg table:",
{r[0]: r[1] for r in sql(f"SHOW TBLPROPERTIES {DB}.sales").collect()}.get("gc.enabled", "(unset, default true)"))
print("\n== dropping the Iceberg table with PURGE ==")
sql(f"DROP TABLE {DB}.sales PURGE")
attempt("Hive rows afterwards", f"SELECT count(*) FROM {DB}.legacy_sales")
spark.stop()
Checks from Hive, Trino and storage
# Hive reads the Hive table, the migrated table with engine.hive.enabled, and
# (one query at a time, so the failure does not stop the rest) the one without it
for t in legacy_e legacy_c legacy_d; do
echo "hive: $t"
docker exec hive-server beeline -u jdbc:hive2://localhost:10000 -n hive --silent=true \
--outputformat=tsv2 -e "SELECT count(*) FROM blog_hive_migration.$t" 2>&1 \
| grep -oE '^[0-9]+$|Cannot create an instance of InputFormat class [^ ]+' | sort -u
done
# Trino reads the migrated table through its iceberg catalog, not its hive catalog
docker exec trino trino --execute "SELECT count(*) FROM iceberg.blog_hive_migration.legacy_c"
docker exec trino trino --execute "SELECT count(*) FROM hive.blog_hive_migration.legacy_c"
# file formats and file count under a Hive table's location, before migrating it
docker exec mc /usr/bin/mc ls --recursive minio/warehouse/blog_hive_migration.db/legacy_a/ \
| awk '{print $NF}' | sed 's/.*\.//' | sort | uniq -c
docker exec mc /usr/bin/mc ls --recursive minio/warehouse/blog_hive_migration.db/legacy_a/ | wc -l
Clean up
# Drop the tables first, through Iceberg's session catalog: Spark's own Hive client
# cannot load the storage handler that engine.hive.enabled puts on legacy_c, so a
# plain DROP DATABASE ... CASCADE fails on it. Delete the directories only if every
# drop succeeded; removing an Iceberg table's metadata first leaves an entry that
# can no longer be dropped.
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-sql --master "local[1]" --driver-memory 1g \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
--conf spark.sql.catalog.spark_catalog=org.apache.iceberg.spark.SparkSessionCatalog \
--conf spark.sql.catalog.spark_catalog.type=hive \
--conf spark.sql.catalog.spark_catalog.uri=thrift://hive-metastore:9083 \
-e "DROP TABLE IF EXISTS blog_hive_migration.legacy_c;
DROP TABLE IF EXISTS blog_hive_migration.legacy_a_copy;
DROP TABLE IF EXISTS blog_hive_migration.legacy_a;
DROP TABLE IF EXISTS blog_hive_migration.legacy_b;
DROP TABLE IF EXISTS blog_hive_migration.legacy_b_BACKUP_;
DROP TABLE IF EXISTS blog_hive_migration.legacy_d;
DROP TABLE IF EXISTS blog_hive_migration.legacy_e;
DROP TABLE IF EXISTS blog_hive_migration.legacy_sales;
DROP DATABASE blog_hive_migration"' 2>/dev/null \
&& docker exec mc /usr/bin/mc rm --recursive --force minio/warehouse/blog_hive_migration.db/ \
&& docker exec mc /usr/bin/mc rm --recursive --force minio/warehouse/blog_hive_migration_ext/
The && matters: if any drop fails, the directories stay, and the tables can
still be dropped properly. Deleting an Iceberg table’s metadata before dropping it
leaves a metastore entry that Iceberg can no longer load, and so can no longer
drop. HiveServer2 also caches its hive-exec jar under s3a://warehouse/user/hive/
the first time it runs a query; that is Hive’s, not the scripts’, and it reuses it.
Common misconceptions
“Migration rewrites the data.” Neither procedure rewrites a data file. Both
write metadata over the files already present, although migrate’s backup rename
can move them on a Hive 4 metastore.
“A snapshot copy stays in sync.” It reflects the files at conversion time. Later writes to the source do not appear in it, and writes to the copy do not reach the source.
“Dropping the copy is dangerous.” A snapshot copy has gc.enabled=false, so
PURGE is refused. The dangerous drops are the migrated table and an add_files
target, which keep gc.enabled=true.
“Migration fixes small files.” It inherits them, and gives you the statistics to see them clearly.
“Migration is instant regardless of table size.” It scales with the number of files, because every Parquet or ORC footer is read for statistics.
“I can migrate from any catalog.” migrate requires SparkSessionCatalog as
the session catalog. snapshot does not.
A model worth keeping
Migration is a metadata operation over data that already exists. snapshot gives
you a copy to be wrong against, protected from PURGE; migrate gives you the
cutover and a backup that shares the same files. Doing them in that order costs
one extra step and converts an irreversible change into a rehearsed one.
Everything that makes Iceberg fast, compaction, sort orders and transform partitioning, comes after the cutover, not during it.
References
- Iceberg Spark procedures for
snapshot,migrateandadd_files - Iceberg Hive migration for the supported source formats and caveats
MigrateTableSparkActionandSnapshotTableSparkActionfor the rename, the backup and the properties each sets- Apache Iceberg architecture for the metadata tree these procedures construct
- Table maintenance for Iceberg and Hudi for the compaction that should follow a migration
Trademarks
Apache Iceberg, Apache Spark, Apache Hive, 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. Trino is a trademark of the Trino Software Foundation. MinIO is a trademark of MinIO, Inc.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.