Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Added¶
Project logo. The docs show it in the sidebar, in a light and a dark version, and use it as a favicon that follows the browser’s light or dark theme. Links to the docs get a preview image (Open Graph), and the README shows the logo next to its introduction.
0.1.0 - 2026-11-08¶
First public release of iceberg-ops: Alembic-style schema migrations
for Apache Iceberg, plus a cross-table data mover, basic maintenance
helpers and an experimental Apache Polaris policy executor.
Development builds from May 2026 were never published. The Changed and Fixed lists below describe how this release differs from them, for anyone who ran those builds from source.
Added¶
Schema migrations:
BaseMigrationandMigrationContextformigrations/NNNN_*.pyfiles withupgrade()/downgrade()and thepre_upgrade,post_upgrade,pre_downgradeandpost_downgradehooks. Context helpers:create_table_if_not_exists(withpartition_spec,sort_order,propertiesandlocation, applied on create),drop_table_if_exists,load_table,add_column,enforce_column(with opt-inlegacy_archival),enforce_rename,delete_column,update_partition_spec,replace_sort_order,set_clustering_keys,set_table_properties,unset_table_properties,set_snapshot_retentionandset_compaction_target_size. Helpers take bare table names; the runner adds the namespace.MigrationRunnerwithmigrate,rollback,stamp,plan,statusandhistoryfor one namespace at a time. Migration history is an Iceberg table,_iceberg_ops.migration_history, in the managed catalog; it keeps the latest status of each version per namespace.iceberg_ops.runner.normalize_version:"2","0002"and2name the same version.The
iceberg-opsCLI:init,revision,migrate,rollback,stamp,plan,status,current,history,migrate-dataandrun-policies, plus--version. Settings come from flags or fromiceberg-ops.yaml(keyscatalog,catalog_name,namespace,db_path,warehouse,migrations_dir).--catalog-name NAMEand the config keycatalog_nameopen any catalog defined in.pyiceberg.yamlorPYICEBERG_CATALOG__<NAME>__*environment variables.migrate-datahas--src-catalog-name/--dst-catalog-nameandrun-policieshas--catalog-name.Autogenerate (
iceberg_ops.autogen):schema_diff_to_ops,spec_diff_to_ops,sort_order_diff_to_ops,properties_diff_to_ops,render_migration_body,render_migration_fileandwrite_autogenerated_revision, plusiceberg-ops revision --autogenerate --target-table Twith--target-schema-yaml FILEor--target-schema-arrow module:attrand repeatable--rename-hint old:new. The CLI diffs columns; the Python API also diffs partition spec, sort order and table properties. Rename hints are validated, and new columns keep their target doc.YAML schema contracts (
SchemaContract.from_yaml) accept the Iceberg primitive types, includingdecimal(P,S),fixed[N]andtimestamp_ns, and are validated with clear errors.Core helpers (
iceberg_ops.core):CatalogFactory.buildfor thesqlite,memory,glue,rest,hiveands3tableskinds,default_local_paths(), Arrow-to-Iceberg type translation withis_allowed_promotion(old, new)(Arrow time, uuid, fixed-size binary, string_view, binary_view and dictionary types are mapped), schema diff,SchemaContract,SchemaEvolutionHelper, and snapshot, upsert, time-travel and field-history utilities.
Companion modules:
Cross-table data migration, experimental (
iceberg_ops.data_migration,iceberg-ops migrate-data):SchemaReconciler,CheckpointStoreandDataMigratorwithappend,upsertandoverwrite_partitionwrite modes, checkpoint resume, dry runs androw_count/partition_hash/sampleverification.MigrationResultreportsdest_snapshot_before(a rollback target),rows_insertedandrows_updated;CheckpointStore.jobexposes the recorded job settings. Every snapshot the migrator writes carriesiceberg-ops.migration-jobandiceberg-ops.migration-batchsummary properties.Basic maintenance helpers for self-managed tables (
iceberg_ops.maintenance):compact_small_files_streaming,expire_snapshots,remove_orphan_filesandtable_health_report.SnapshotExpiryResult.protected_idslists snapshots kept because a branch or tag points at them;OrphanCleanupResult.failed_pathslists orphans whose deletion failed.Apache Polaris policy executor, experimental (
iceberg_ops.polaris,iceberg-ops run-policies):PolarisClient,Policy,parse_policy,fetch_table_policies,dispatch_policy,dispatch_policiesand ahandlers={...}hook for custom policy types. Onlysystem.snapshot-expiryis executed.iceberg_ops.testing:FakeResponseandFakeSessiontest doubles for driving the Polaris client without HTTP. They may change before 1.0.
Packaging and project:
Extras:
sqlite,glue,hive,s3tables,transforms(needed to write to tables partitioned by bucket, truncate or time transforms) andpolaris.restandcontractsare accepted but add nothing to the core install.A
py.typedmarker, so type checkers use the package’s annotations.Documentation at https://namitkewat.github.io/iceberg-ops/, including a compatibility page and plain-text
llms.txt/llms-full.txtexports of the docs.CONTRIBUTING.md,CODE_OF_CONDUCT.md(Contributor Covenant 3.0),SECURITY.md(GitHub private vulnerability reporting),ROADMAP.md, issue forms and a pull request template.AGENTS.md, a guide for coding agents working on the repository, andagent-skills/iceberg-ops-migrations/SKILL.md, a skill that teaches a coding agent to write, review and run iceberg-ops migrations.
Changed¶
Packaging and CLI:
The command-line tool is
iceberg-opsonly, and its config file isiceberg-ops.yaml. The earlier short script name and its config file name were dropped: that name belongs to an unrelated project.PyIceberg is pinned to
>=0.12.0,<0.13(was>=0.11.0,<0.12) and PyArrow to>=18. Python 3.14 is tested in CI. PyYAML is a core dependency, andtzdatais installed on Windows.The
[maintenance]extra was removed: it only installed boto3, which no module imported.[s3tables]installspyiceberg[rest-sigv4];[rest]and[polaris]no longer request apyiceberg[rest]extra, which PyIceberg does not define.Development status is Alpha.
Default local state: without
db_path/warehouse_dir,CatalogFactory.build("sqlite")uses./local_data/in the current working directory at call time, and the CLI’s sqlite catalog useslocal_data/next to the config file (or in the current directory when there is none).CatalogFactory.build("memory")withoutwarehouse_dirwrites data files to a fresh temporary directory.The
glue,rest,hiveands3tableskinds are loaded throughpyiceberg.catalog.load_catalog, so.pyiceberg.yamlandPYICEBERG_CATALOG__<KIND>__*variables apply; explicit arguments win.db_pathwith a remote kind raisesValueError, and the local kinds rejecturi/warehousekeyword arguments.The CLI no longer accepts the memory catalog kind, which forgot applied migrations after each command. Catalog-kind options are case-insensitive, and a typo is a usage error.
Relative paths in the config file (
db_path, the sqlitewarehouse,migrations_dir) resolve against the config file’s directory, andmigrations/defaults to sitting next to it. Paths given as flags stay relative to the current directory.CLI exit codes: 2 for usage errors (bad flags or config, missing catalog, namespace or migrations directory, invalid
--target/--steps); 1 for runtime failures, with a singleError:line. SetICEBERG_OPS_TRACEBACK=1to see the traceback.A missing migrations directory is an error (
FileNotFoundErrorfrom the runner, a usage error in the CLI) instead of reporting that all migrations are up to date. An explicit--configpath that does not exist is a usage error, except forinit.
Migrations:
A migration’s
versionmust equal its file’sNNNNprefix, and two files with the same version are rejected.revisionnumbers new files by the sameNNNN_<description>.pyrule the runner uses.plan,statusandhistoryno longer create the_iceberg_opsnamespace or the history table; the first write creates them.--target/target=is compared as a version number:2means0002, and an unknown or malformed target raisesValueError(CLI: exit 2) before anything is applied.rollback(target="0000")rolls back every migration.rollback --stepsmust be 1 or more, defaults to 1 and cannot be combined with--target.enforce_columnadds a missing column, does nothing for an equivalent type (only a changed doc is written), updates a promotable primitive type in place, and otherwise raisespyiceberg.exceptions.ValidationErrorunlesslegacy_archival=True. Promotions follow Iceberg’s rules, so string-to-binary and decimal scale changes are rejected.set_clustering_keyssets only the Iceberg sort order, in one commit. It no longer writessnowflake.cluster-byordelta.clusteringtable properties.set_snapshot_retentionlogs a warning when the catalog is AWS S3 Tables, which manages snapshot retention itself.ConcurrentMigrationErroris defined iniceberg_ops.historyand is still importable fromiceberg_ops.runner.revisionnever overwrites an existing migration file (exit 1).write_autogenerated_revisionrefuses to overwrite one unlessoverwrite=True, and always writes LF line endings.
Autogenerate:
A bare
--target-tablestays bare in the generated file, so the migration applies to whichever--namespaceit runs against. A dotted name pins the file to that table and prints a warning.A generated
upgrade()ordowngrade()raisesNotImplementedErrorbefore any step runs when one of its steps cannot run or cannot be undone (a column drop, a type widening, a partition-field removal, a sort-order replacement, a property removal with an unknown old value). The generated calls are kept as comments.Type-change downgrades are no longer generated as narrowing calls, since Iceberg only widens types; they raise
NotImplementedError.A non-strict
SchemaContract(the default) keeps the table columns it does not list, and the CLI reports them. Setstrict: trueto drop them. Nullability and doc differences on existing columns are reported, not emitted.
Data migration and maintenance:
write_mode="overwrite_partition"needs a destination partitioned by identity transforms only, and the migration must fit in one commit (at mostbatch_rowssource rows after the row filter). Anything else is rejected before any write.Checkpoints record the job’s settings (source and destination table UUIDs, row filter,
batch_rows, write mode, upsert keys and reconciler). Resuming with different settings, a recreated destination or a checkpoint without these settings raisesCheckpointError, which is now aRuntimeError. A completed checkpoint is terminal: callCheckpointStore.reset()to run again.MigrationResult.new_snapshotslists every destination snapshot the run created (up to three per upsert batch, or per 1,000-key sub-batch for composite keys), andCheckpointStore.mark_committedaccepts a list of snapshot IDs.DataMigratorrejects blank or repeatedupsert_keys,upsert_keyswith a write mode other than upsert, and upsert keys missing from the projected destination schema. An upsert withon_missing_in_source="skip"that would drop destination columns is rejected; with"null", the plan warns that matched keys get those values overwritten with null.SchemaReconciler.compute_planreports acolumn_mappingkey that is not a source column.migrate-data --batch-rowsand--sample-sizemust be at least 1,--upsert-keysignores empty entries, and a missing destination table explains thatmigrate-datadoes not create it.remove_orphan_filesdefaults todry_run=True; passdry_run=Falseto delete. It refuses tables withgc.enabled=false(in a dry run too), rejects a negativeolder_than_hoursandsweepentries that are absolute or escape the table location, and warns whenolder_than_hoursis below 24.deletedis True only when at least one file was removed.All
expire_snapshotsarguments aftertableare keyword-only, andmax_age_hoursaccepts fractional hours. Omitted arguments fall back to the table’shistory.expire.max-snapshot-age-ms/history.expire.min-snapshots-to-keepproperties (as written byset_snapshot_retention), then to the Iceberg defaults of 5 days and 1 snapshot. Invalid values raiseValueError. The fallback to PyIceberg’s internalExpireSnapshotsclass for releases older than 0.11 was removed.compact_small_files_streaminglost its unusedbatch_rowsparameter.
Polaris (experimental):
Only
system.snapshot-expiryis executed, and only with"enable": true(a JSON boolean).system.metadata-compactionno longer triggers a data-file rewrite; it,system.data-compactionandsystem.orphan-file-removalare reported as skipped.system.snapshot-expiryreads the Polaris schema (config.max_snapshot_age_days,config.min_snapshot_to_keep;max_ref_age_daysis ignored with a note). Legacy top-level*_mskeys, unknown top-level keys, unknown schema versions and invalid values fail the dispatch instead of running on defaults.A custom handler returning
Noneyields empty details; any other non-dict return marks the dispatch as failed.run-policiesis marked[experimental]and prints a warning when run, because its requests do not yet match the released Polaris policy API.
Fixed¶
Migrations and CLI:
Two concurrent
migrateruns for the same namespace no longer leave duplicate history rows on PyIceberg 0.12 (apache/iceberg-python#4194). The run that records second raisesConcurrentMigrationError, and history keeps one row per version and namespace. Existing duplicate rows are read as their latest status and removed by the next write for that version.When a migration’s own commit conflicts with a concurrent commit, it raises
ConcurrentMigrationErrorand records nothing, instead of overwriting the other run’s history withfailed.migrate --target 2no longer applies every migration,stamp --target 2no longer stamps all of them,rollback --target 1is no longer a no-op, and"2"no longer also selects0010.rollback(steps=0)and a negativestepsraiseValueErrorinstead of rolling back an unexpected number of migrations.rollbackchecks that every migration file it needs exists before running anydowngrade().statuslists history entries whose migration file is missing.enforce_columnis idempotent for struct, list and map columns.enforce_column(..., legacy_archival=True)archives only on a real type incompatibility. Commit errors and failing contracts propagate instead of archiving the column and adding an empty replacement.Archival, in
enforce_columnand in theenforce_renamecollision path, refuses columns used by the partition spec, sort order or identifier fields. An archived required column becomes optional, so later appends no longer fail. If archival or the rename fails part way, the column is restored with its original doc and required flag; if that restore also fails, aRuntimeErroris raised.enforce_rename(name, name)is a no-op, and renaming across different parent structs raisesValueError.enforce_columnandadd_columnaccept primitive PyArrow types such aspa.float64()andpa.string().Migration files that define a
@dataclass(withfrom __future__ import annotations) load. Same-named files in different namespace directories no longer collide. A broken migration file raises an error that names the file, and the CLI prints it as oneError:line.create_table_if_not_existsworks for nested namespaces such assales.eu, and returns the existing table if another process creates it first.set_table_propertiesaccepts a property key namedproperties.revision --autogeneratehonours--catalog-name, the config’scatalog_nameand the config-relative sqlite defaults.Missing extras, missing tables, missing catalog properties, connection failures, permission or server errors, commit conflicts and corrupt checkpoints print a single
Error:line and exit 1 instead of a traceback.Config file problems (invalid YAML, a non-mapping top level, non-string values such as
namespace: 123) are usage errors. Unknown keys print a warning.Config and generated files are read and written as UTF-8 (a BOM is tolerated).
initappends its block to.gitignorewithout rewriting the existing content, and honours--configand--migrations-dir.Using the
s3tableskind without boto3 says to installiceberg-ops[s3tables].CatalogFactory.build(..., reset=True)refuses to delete a filesystem root, the home directory, the working directory or one of its parents, or a database file that is not SQLite.The
sqliteandmemorykinds reject a warehouse that looks like a URI and local paths containing#or?, which PyIceberg would misread, before anything is written.The history table is created with
commit.retry.num-retries=20, so runners for different namespaces sharing it no longer lose history writes to commit contention.When recording a failure in history itself fails, the migration’s own error is still the one raised; the history error is logged.
historysizes its columns to their contents, so full ISO-8601 timestamps no longer push DESCRIPTION out of line, and a multi-line error is printed on one line.create_table_if_not_existsraisesValueErrorbefore creating anything when apa.Schemais combined with a non-emptypartition_specorsort_order.SchemaContract.validatecompares struct, list and map types without regard to field IDs, andenforce_renamechecks the contract when neither column exists.
Autogenerate:
revision --autogeneratepasses the table’s partition spec, sort order and format version to the diff. Dropping a column that the spec, the sort order or the identifier fields use makes the generatedupgrade()raise before any step runs, and a type the table’s format version cannot hold is a usage error. Column names containing., decimals with a scale outside0..precisionor more than 38 digits, andfixed[0]are rejected too. Existing nested columns of a--target-schema-arrowtarget that were not compared are named in a warning instead of reporting no differences.Partition and sort fields sourced from a nested column render as the full dotted path and bind to the nested field.
Autogenerate no longer emits type changes that Iceberg does not allow, such as a decimal scale change, string to long, or binary to string; such a step makes
upgrade()raise before anything runs.A generated
downgrade()with an irreversible step no longer rolls back part of the migration and then fails.Nested type changes (struct, list, map) in a
pyiceberg.schema.Schematarget (Python API) produce a file that compiles, with the step blocked for manual completion, instead of a syntax error.Invalid YAML contracts, unknown types and bad rename hints make
revision --autogenerateexit with a usage error.Partition fields with non-identity transforms (day, bucket, …) import their transform and run. Sort-order diffs compare transform and null order, and reject orders the generated call cannot express.
--target-schema-arrowmodules are importable from the current directory when the CLI runs from the installed console script.
Data migration:
overwrite_partitioncould delete rows written by an earlier batch of the same migration; it is now limited to one commit (see Changed).A resume reads the source snapshot recorded in the checkpoint, so rows added to the source after a crash are neither skipped nor duplicated.
verify()after a run compares against the snapshot that was migrated.Re-running with a checkpoint that records a completed migration raises
CheckpointErrorinstead of duplicating and dropping rows.A batch committed just before a crash, but not yet checkpointed, is not written twice on resume.
Pre-flight runs every check before it changes the destination, so auto-added columns are no longer committed when a later check fails.
Writing to a destination partitioned by bucket, truncate, year, month, day or hour without
pyiceberg-corefails before any write with anImportErrornamingpip install 'iceberg-ops[transforms]'.The first upsert after a schema-only change (for example an auto-added column) no longer fails; an empty snapshot is committed first, as a workaround for apache/iceberg-python#2467.
Composite-key upserts with more than a few thousand keys per batch no longer crash the interpreter (apache/iceberg-python#3508). Large batches are split into sub-batches of 1,000 keys within one commit.
When the source schema changed after the pinned snapshot, the plan uses the schema at that snapshot.
verify(method="partition_hash")works when a partition field’s name differs from its source column, and raisesNotImplementedErrorfor non-identity partitions instead of reporting a mismatch (the CLI printsVerify error:and exits 1).A resume raises
CheckpointErrorwhen the destination was rolled back past a checkpointed batch, instead of skipping that batch’s rows. Every refusal that follows committed batches names the snapshot to roll back to beforereset().Upsert keys that the plan would fill with null are rejected before any write.
DataMigratorandmigrate-datarefuse a source and destination that are the same table.Checkpoint writes are fsync-ed before the atomic rename.
Maintenance:
remove_orphan_filesno longer deletes live position-delete, equality-delete and deletion-vector files, or registered table and partition statistics files.compact_small_files_streamingno longer deletes its staged files when the commit outcome is unknown (for exampleCommitStateUnknownException), which could make a committed table unreadable.expire_snapshotsskips branch and tag heads (including the current snapshot after a rollback) instead of aborting, and dry runs select the same snapshots as real runs. A max age older than the epoch expires nothing instead of overflowing.Compaction and orphan removal accept Windows drive-letter table locations.
Security¶
Generated migration files cannot run injected code: the
-mmessage and column, partition and property names and values are written as Python literals, and docstring text is escaped.Polaris OAuth2 token-endpoint errors no longer echo the response payload, which could leak tokens to logs or stderr. The client strips surrounding whitespace from a token, rejects an empty token or one with inner whitespace or control characters (
run-policiesexits 2), and keeps the token and client secret out of connection-error messages.A failed migration’s history row holds only
"<Type>: <message>"; the traceback is logged locally atDEBUGlevel, since everyone with catalog access can read the history table.remove_orphan_filesmatches file names after Unicode NFC normalisation and case folding, does not follow directory symlinks or Windows junctions, and never deletes a file whose real path is outside the table root.Compaction escapes identity-partition values the way PyIceberg does and refuses to stage files outside
<table>/data, so a partition value such as../../xcannot write elsewhere.run-policiesreadsICEBERG_OPS_POLARIS_TOKEN,ICEBERG_OPS_POLARIS_CLIENT_IDandICEBERG_OPS_POLARIS_CLIENT_SECRET(preferred over flags, which show up in process listings) and warns when--polaris-urluses plain http to a non-loopback host.
Known limitations¶
Concurrent runs for one namespace are not guarded, only detected: a second run can execute
upgrade()before it fails to record history. Run one migration job per namespace at a time and keepupgrade()idempotent.The history table keeps the latest status of each version per namespace, not one row per attempt. Earlier states are readable by time travel on the history table until its snapshots expire.
status,current,planandhistoryprint only and exit 0 even when a migration is recorded as failed; there is nocheckcommand yet. The runner methods print their reports and returnNone.CLI autogenerate diffs columns only. Autogenerate does not emit nullability or doc changes, blocks nested type changes from a
pyiceberg.schema.Schematarget, and does not compare existing nested columns of apyarrow.Schematarget (they are treated as unchanged; the CLI names them in a warning). Each generated step commits on its own, so a failing step leaves earlier steps applied.Only SQLite and in-memory catalogs are integration-tested. Glue, REST, Hive and S3 Tables catalogs are reached through PyIceberg but have not been tested against live services.
Data migration:
overwrite_partitionis limited to one commit and identity-partitioned destinations; the destination table must already exist.batch_rowsbounds the rows per commit, not peak memory, which grows with the number and size of source data files (apache/iceberg-python#2407); an upsert also loads the matched destination rows. Duplicate keys within one batch raiseValueError, and across batches the later row wins.verify="sample"can report a false mismatch on larger tables, andpartition_hashneeds an identity-partitioned or unpartitioned destination and reads both tables in full.Maintenance: compaction and orphan removal support the local filesystem only. Compaction supports identity partitions and unpartitioned tables (other partitions are skipped), rewrites qualifying partitions whole, writes
2N + 1snapshots forNpartitions, uses PyArrow’s default codec, fails when a concurrent append lands in a partition being compacted, and its memory is not bounded bytarget_size_mb(apache/iceberg-python#2407). Delete files and deletion vectors are out of scope for compaction (there is no guard) and the health report. Snapshot expiry is metadata-only.The Polaris executor does not call the Polaris 1.x policy endpoints, so fetching policies from a released Polaris server fails. It has not been validated against a live server.
Python 3.15 is not tested. At release time PyIceberg 0.12.0 published no Python 3.15 wheels.
PyArrow 26 and later refuse to import next to NumPy 1.x. iceberg-ops does not use NumPy; in an environment pinned to NumPy 1.x, also pin
pyarrow<26.On Windows, write a local warehouse in
.pyiceberg.yamlasfile://C:/path; PyArrow rejects thefile:///C:/pathform.