Why I chose this topic: I’ve spent the last six months cleaning up the aftermath of "in-place" migrations that nuked production partitions, and I’m tired of seeing engineers treat schema evolution like a suggestion rather than a requirement. If you aren't running parallel pipelines during a migration, you aren't doing engineering; you're playing roulette with your data lake's consistency.
You’ve hit the limit. You’re running MSCK REPAIR TABLE for the thousandth time, your Spark jobs are failing because a downstream process added a column to a Parquet file without telling anyone, and your S3 list latency is becoming a full-blown outage. You read the blog posts about "converting" your tables to Iceberg in place. Don’t. If you run a conversion script on a multi-petabyte production table, you are betting your entire career on the hope that the conversion process doesn't hit a transient I/O error mid-write.
Most engineers try to "cut over" by pointing the metastore to a new location or running an ALTER TABLE conversion. When that fails—and it will fail when a stray job tries to write to the old partition layout at the same time—your state becomes inconsistent. You end up with a mix of hidden files, phantom partitions, and a massive ticket queue from the BI team asking why their dashboards are returning nulls.
The real problem
The problem isn't the file format; it's the metadata management. Hive-style partitioning is a legacy relic that relies on filesystem structure to define schema. This is inherently fragile. When you migrate, you shouldn't be "converting" data; you should be building a dual-write architecture that treats the new Iceberg table as the source of truth while keeping the legacy Parquet table as a hot-standby fallback.
Photo by Michael Evans on Unsplash
Step 1: The dual-write bridge
Before you touch your production tables, set up a Spark Structured Streaming job that consumes the same upstream raw data or Kafka topic that feeds your existing Parquet pipeline. Do not attempt to "copy" existing files into Iceberg. Instead, create a brand-new Iceberg table and let the streaming job backfill it.
Configure your Spark session to point to your Catalog—I prefer the REST catalog for multi-engine support—and define the Iceberg schema explicitly. Do not rely on schema inference. It will bite you the moment a decimal(10,2) turns into a decimal(38,18) during a silent upstream change.
val spark = SparkSession.builder()
.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.sql.catalog.prod_catalog", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.prod_catalog.type", "rest")
.config("spark.sql.catalog.prod_catalog.uri", "https://your-iceberg-catalog-service")
.getOrCreate()
// Create the target Iceberg table
spark.sql("""
CREATE TABLE prod_catalog.db.orders_iceberg (
order_id bigint,
user_id bigint,
amount decimal(10,2),
ts timestamp
) USING iceberg
PARTITIONED BY (days(ts))
""")
Step 2: Validating the shadow state
Once your shadow table is streaming, you need to verify it. Don't just check record counts; count checksums. A record count of 1 million rows in Parquet vs 1 million in Iceberg means nothing if the schema types don't align.
I run a validation job every hour that compares the sum(amount) and max(ts) between the legacy Parquet table and the Iceberg shadow table. If these don't match, you trigger an alert. If they do match, you have high confidence that your streaming logic is sound.
# Validation check in PySpark
parquet_df = spark.read.table("hive_metastore.db.orders_old")
iceberg_df = spark.read.table("prod_catalog.db.orders_iceberg")
def get_stats(df):
return df.agg(sum("amount").alias("total"), count("*").alias("cnt")).collect()
# Compare results
if get_stats(parquet_df) != get_stats(iceberg_df):
raise Exception("Integrity mismatch between Parquet and Iceberg")
Step 3: The hidden cutover
The biggest mistake is a "big bang" switch. Instead, use a view to abstract the table location. Create a view that initially points to your legacy Parquet table. When you are ready to flip the switch, you update the view definition to point to the Iceberg table. This allows you to toggle back in seconds if your BI tools start throwing errors.
Crucially, ensure your Iceberg table is configured with write.format.default = parquet and write.metadata.delete-after-commit.enabled = true. You want the performance of Iceberg with the compatibility of Parquet files underneath.
-- Initially
CREATE VIEW prod_catalog.db.orders_view AS
SELECT * FROM hive_metastore.db.orders_old;
-- During migration
-- Update the view to point to the new Iceberg table
ALTER VIEW prod_catalog.db.orders_view AS
SELECT * FROM prod_catalog.db.orders_iceberg;
Photo by Daniel Miksha on Unsplash
Lessons learned from production
- Partition evolution is your friend: Unlike Hive, Iceberg allows you to change partition schemes without rewriting the entire table. Don't be afraid to start with
days(ts)and move tohours(ts)if query performance drops as the table grows. - Watch the
metadata-logfolder: If you are using S3, metadata files can grow significantly. Setwrite.metadata.delete-after-commit.enabledtotrueand keepwrite.metadata.previous-versions-maxlow (e.g., 5-10) unless you have a strict regulatory requirement to keep months of metadata history. - Snapshot isolation is not magic: If your downstream jobs use
spark.read, they will see the current snapshot. If a long-running job starts before you switch the view and ends after, it might see inconsistent data if you are not careful with snapshot expiration. Set yourexpire_snapshotsto run every 24 hours to prevent your storage costs from exploding due to dangling files. - Handle empty writes: If your upstream source has gaps, some Spark streaming configurations will write empty Iceberg snapshots. This creates unnecessary metadata overhead. Filter out empty micro-batches before calling
write.
Conclusion
Migrating to Iceberg is less about the data and more about the orchestration. If you treat your migration like a controlled release—with a shadow table, validation logic, and a view-based abstraction—you remove the "fear" factor of the migration. You aren't just moving files; you're building a system that allows you to evolve your schema without breaking the downstream.
Stop relying on the filesystem to define your data structure. Move to Iceberg, keep your legacy tables as a hot-standby, and validate, validate, validate.
Try it: Start your shadow pipeline today. Create the Iceberg table, stream to it for one week, and run a daily diff between it and your legacy Parquet table. If the data matches for 7 straight days, you're ready to cut over.
Tags: #data #iceberg #engineering #cloud
Cover photo by David Pupăză on Unsplash.
Top comments (0)