Cross-Table Data Migration¶
The iceberg_ops.data_migration subpackage moves rows between two
existing Iceberg tables. It handles schema reconciliation (renames,
type coercions, missing-column policies), checkpoint-based resume, and
three post-migration verify methods. Source and destination can live
in different catalogs: pass any two PyIceberg tables.
The destination table must already exist (create it with a schema migration first); the migrator never creates tables.
Note
Data migration is a companion to the schema-migration workflow and is early-stage. Read the write-mode, resume and verify limits on this page before you point it at data you care about. Its API may change before 1.0.
Three composable pieces¶
SchemaReconciler builds a ReconciliationPlan from a (source,
dest) schema pair and projects each pyarrow.RecordBatch against it.
Handles column renames via column_mapping (every key must be a
source column), per-column type_coercions (for a column the
destination already has, the override must equal its type or be a
legal Iceberg promotion of it), and two missing-column policies:
on_missing_in_source–"error"(default),"null", or"skip".on_missing_in_dest–"error"(default) or"auto_add".
Static lossy-cast detection (narrowing integer / float casts, float to
integer, timestamp -> date) surfaces warnings without
blocking; data-dependent failures still raise inside
project_batch.
CheckpointStore persists per-batch progress to a JSON file with
atomic writes (tmp file, fsync, os.replace). Disk I/O is
lazy. A corrupt, unreadable or unsupported-version file raises
CheckpointError (a RuntimeError) with a remediation message.
Single-writer model; one checkpoint file per migration job.
DataMigrator wires the two together with a streaming loop: it
reads the source at one snapshot, accumulates up to batch_rows
source rows, projects them, and writes them in one destination
commit. Pre-flight runs every check before it writes anything to the
destination (auto-added columns included).
Write modes¶
"append"– oneTable.appendper batch."upsert"– requiresupsert_keys; uses PyIceberg’s nativeTable.upsert(see Upserts)."overwrite_partition"–Table.dynamic_partition_overwrite. In this release it needs a destination partitioned by identity transforms only, and the whole migration must fit in one commit (at mostbatch_rowssource rows after the row filter); a second commit would delete the rows the first one wrote to the same partitions. Pre-flight (dry runs included) rejects anything else withValueErrorbefore any write: raisebatch_rowsor useappend/upsert.
Memory¶
batch_rows bounds the rows in each commit, not peak memory.
PyIceberg’s batch reader decodes whole source data files at a time
(apache/iceberg-python#2407), so peak
memory grows with the number and size of the source data files. An
upsert also loads the destination rows that match each batch’s keys.
Checkpoints and resume¶
Attach a CheckpointStore to make a run resumable. A crashed run
resumes from the last committed batch on the next invocation, reading
the source at the snapshot stored in the checkpoint (not the source’s
current snapshot), so rows added to the source after the crash are
neither skipped nor duplicated.
The checkpoint records the job’s settings: source and destination
table UUIDs, row filter, batch_rows, write mode, upsert keys and
reconciler settings. A resume raises CheckpointError before writing
when:
the checkpoint already records a completed migration (a completed checkpoint is terminal: call
CheckpointStore.reset()or delete the file to run again);any recorded setting differs (for example a changed
batch_rowsor row filter, or a recreated destination table), or the checkpoint has no recorded settings;the recorded source snapshot no longer exists (expired), or an explicit
source_snapshotdiffers from it;the destination’s current history no longer holds a checkpointed batch’s snapshots (it was rolled back): resuming would skip those rows.
CheckpointStore.reset() forgets which batches are already in the
destination. When batches were committed, every refusal names the
job’s dest_snapshot_before. Roll the destination back to it with
rollback_to_snapshot (or delete the job’s rows) before you reset
and re-run, or an append writes those rows again.
Every destination snapshot a batch creates carries
iceberg-ops.migration-job and iceberg-ops.migration-batch
summary properties; the empty schema-stamp snapshot an upsert may
commit first carries only iceberg-ops.migration-job. On resume, a
batch that was committed just before a crash but not yet checkpointed
is found through them and recorded instead of being written twice.
Upserts¶
Two rows with the same key inside one batch raise
ValueError(earlier batches stay committed). Across batches the later row wins, so the outcome for duplicate keys depends onbatch_rows.on_missing_in_source="skip"is rejected for upserts (an upsert writes every destination column). With"null", matched keys get those columns overwritten with null; the plan carries a warning.upsert_keysmust be non-blank, unique, present in the projected destination schema, and mapped from a source column (a key the plan would fill with null is rejected before any write); they are rejected with any other write mode.Composite-key batches of more than 1,000 rows are split into sub-batches inside one transaction, so each batch is still one catalog commit (works around apache/iceberg-python#3508).
When the destination’s current snapshot predates its schema (for example right after
auto_add), one empty snapshot is committed first so the upsert can run (workaround for apache/iceberg-python#2467).One batch is one catalog commit but can create up to three destination snapshots (three per 1,000-key sub-batch when a composite-key batch is split), plus the optional empty snapshot above.
MigrationResult.new_snapshotslists all of them, androws_inserted/rows_updatedcount the upsert’s effect. To undo the run, roll back toMigrationResult.dest_snapshot_before(see Undoing a migration), not to the snapshot before the last batch.
Partitioned destinations¶
Writing to a destination partitioned by a bucket, truncate, year,
month, day or hour transform needs pyiceberg-core:
pip install "iceberg-ops[transforms]"
Without it, pre-flight raises ImportError before any write (a dry
run reports it as a plan warning). Identity-partitioned and
unpartitioned destinations do not need the extra.
Verify methods¶
After the migration completes, DataMigrator.verify(method=...)
runs one of three consistency checks. The source is read at the
snapshot the run migrated (an explicit source_snapshot, else the
snapshot the last run() read), so rows appended to the source
afterwards do not count as a mismatch. The destination is read in
full, so the result is meaningful for an append into an empty
destination; account for pre-existing rows or upserts yourself.
"row_count"(default) – compares row counts from manifest metadata; reads data files only where the row filter cannot be decided per file or delete files apply."partition_hash"– per-partition SHA256 of sorted rows. Supports unpartitioned tables and identity-transform partition specs; non-identity partitions raiseNotImplementedError(the CLI printsVerify error:and exits 1). Reads both tables in full."sample"– compares the firstsample_sizerows of each side after sorting. Reliable only when both tables fit in the sample or the read order is the same on both sides; otherwise it can report a false mismatch.
Each method returns a VerificationResult with method,
matches, source_count / dest_count (row_count and
sample), sample_size, mismatched_partitions
(partition_hash) and details (list of strings).
Undoing a migration¶
MigrationResult.dest_snapshot_before is the destination’s current
snapshot before the run wrote anything (for a resumed run, the one
recorded when the job first started); it is None when the
destination had no snapshot. Pass it to
iceberg_ops.core.utils.rollback_to_snapshot(table, snapshot_id) to
put the destination back. Columns that auto_add added stay in the
schema. After a rollback, call CheckpointStore.reset() before you
run the job again: a resume after a rollback raises
CheckpointError.
CLI usage¶
iceberg-ops migrate-data \
--src-catalog sqlite --src-db-path src.db --src-warehouse src_wh \
--src-namespace sales --src-table orders \
--dst-catalog sqlite --dst-db-path dst.db --dst-warehouse dst_wh \
--dst-namespace warehouse --dst-table orders \
--batch-rows 50000 \
--column-mapping order_total:total_amount \
--on-missing-in-source null \
--on-missing-in-dest auto_add \
--checkpoint orders.checkpoint.json \
--verify row_count
To write into another catalog, name it with --dst-catalog-name. The
name is resolved through PyIceberg’s load_catalog, so it is read
from .pyiceberg.yaml or PYICEBERG_CATALOG__<NAME>__*
environment variables:
# .pyiceberg.yaml
catalog:
dest:
type: rest
uri: https://nessie.example.com/iceberg
iceberg-ops migrate-data \
--src-catalog sqlite --src-db-path src.db --src-warehouse src_wh \
--src-namespace sales --src-table orders \
--dst-catalog-name dest --dst-namespace warehouse --dst-table orders
Key flags:
--src-catalog KINDor--src-catalog-name NAME– one of the two is required.--src-db-path/--src-warehouseconfigure a sqlite source.--dst-catalog/--dst-catalog-name– the destination catalog. When neither is given, the destination is the source catalog (useful for cross-namespace migration in one catalog), and the source’s--src-db-path/--src-warehousecarry over only to that same catalog.--dst-table– defaults to--src-table. The table must already exist.--row-filter– PyIceberg row filter expression on the source scan.--source-snapshot– pin the read to a specific source snapshot ID.--batch-rows– source rows per commit (default 50000, at least 1). Bounds rows per commit, not peak memory.--write-mode–append(default) /upsert/overwrite_partition(identity-partitioned destination, one commit of at most--batch-rowsrows; see Write modes).--upsert-keys– comma-separated; required when--write-mode=upsert.--checkpoint– path to the JSON checkpoint file. A second run with the checkpoint of a completed migration exits 1.--column-mapping SRC:DST– repeatable.--dry-run– pre-flight only: runs the checks and counts rows + bytes, does not write.--verify {row_count|partition_hash|sample}– run a post-migration check (skipped on a dry run). A mismatch exits 2.
Errors from the migrator (a refused resume, a missing extra, a missing
destination table, …) print one Error: line and exit 1; set
ICEBERG_OPS_TRACEBACK=1 to see the traceback.
Python API¶
import os
import tempfile
import pyarrow as pa
from iceberg_ops.core.catalog import CatalogFactory
from iceberg_ops.data_migration import CheckpointStore, DataMigrator, SchemaReconciler
work = tempfile.mkdtemp()
# Source: a SQLite catalog with a populated sales.orders table.
src_catalog = CatalogFactory.build(
"sqlite",
db_path=os.path.join(work, "src.db"),
warehouse_dir=os.path.join(work, "src_wh"),
)
src_catalog.create_namespace("sales")
src = src_catalog.create_table(
"sales.orders",
pa.schema([("order_id", pa.int64()), ("order_total", pa.float64()), ("region", pa.string())]),
)
src.append(
pa.table(
{"order_id": [1, 2, 3], "order_total": [9.5, 20.0, 7.25], "region": ["eu", "us", "eu"]}
)
)
# Destination: a second catalog. The table must already exist.
dst_catalog = CatalogFactory.build(
"sqlite",
db_path=os.path.join(work, "dst.db"),
warehouse_dir=os.path.join(work, "dst_wh"),
)
dst_catalog.create_namespace("warehouse")
dst = dst_catalog.create_table(
"warehouse.orders",
pa.schema([("order_id", pa.int64()), ("total_amount", pa.float64()), ("channel", pa.string())]),
)
reconciler = SchemaReconciler(
column_mapping={"order_total": "total_amount"},
on_missing_in_source="null", # 'channel' has no source column
on_missing_in_dest="auto_add", # 'region' is added to the destination
)
migrator = DataMigrator(
source=src,
dest=dst,
schema_reconciler=reconciler,
batch_rows=50_000,
write_mode="append",
checkpoint=CheckpointStore(os.path.join(work, "orders.checkpoint.json")),
)
result = migrator.run()
print(f"moved {result.rows_moved:,} rows in {len(result.new_snapshots)} snapshots")
print(f"columns added to the destination: {result.schema_changes}")
verification = migrator.verify(method="row_count")
assert verification.matches, verification.details
Any PyIceberg catalog works on either side. For example, to load the
destination from a catalog defined in .pyiceberg.yaml:
# illustrative: needs a reachable catalog named "dest"
from pyiceberg.catalog import load_catalog
dst_catalog = load_catalog("dest") # e.g. Nessie at https://nessie.example.com/iceberg
dst = dst_catalog.load_table("warehouse.orders")
Testing seam¶
The iceberg_ops.testing module ships FakeSession / FakeResponse
for integrators driving the Polaris client without HTTP. They are not
needed for DataMigrator itself – the data-migration tests run
against real PyIceberg catalogs.
See Data Migration for the full reference.