Autogenerate¶
iceberg-ops revision --autogenerate compares an existing Iceberg
table against a target schema and writes a migration file whose
upgrade() closes the gap and whose downgrade() undoes it where
the prior state is known.
From the CLI, the target is a YAML
SchemaContractor an importablepa.Schema(module:attr), and only columns are diffed.From Python, the target can also be a
pyiceberg.schema.Schema, andspec_diff_to_ops/sort_order_diff_to_ops/properties_diff_to_opsdiff the partition spec, sort order and table properties.
Generated files always carry a “Review every line before running” warning: autogenerate is a starting point, not a finished implementation. Rename detection in particular requires explicit hints, and steps that cannot run or cannot be reversed make the generated method raise instead of running (see Limitations).
Generated files keep the table name exactly as you pass it. Pass a
bare name (orders): the migration context qualifies it with the
namespace given at migrate time, so one file serves every
namespace. A dotted name (sales.orders) pins the file to that one
table, and the CLI prints a warning.
Diff functions¶
The autogenerate machinery decomposes into four diff helpers, all exposed on the public API:
schema_diff_to_ops(*, current, target, rename_hints=None)emitsadd_column/rename_column/delete_column/type_changeops. Withoutrename_hints, a rename is indistinguishable from a drop + add and the diff falls back to drop + add. With apa.Schemaorpyiceberg.schema.Schematarget, table columns missing from the target are dropped. A non-strictSchemaContracttarget (the default) only reconciles the columns it lists and keeps the rest; a strict one drops unlisted columns.spec_diff_to_ops(*, current, target, schema)emitsadd_partition_field/remove_partition_fieldops for partition spec evolution, matching fields by partition name. The generated call passes the transform astransforms.<Transform>(...)and the file importsfrom pyiceberg import transforms.sort_order_diff_to_ops(*, current, target, schema)emits a singleset_sort_orderop when the orders differ (replace semantics, not delta).properties_diff_to_ops(*, current, target)emitsset_property/remove_propertyops, preserving the previous value in args so the generated downgrade can restore it.
Renderer¶
render_migration_body(ops, *, target_table) returns
(upgrade_body, downgrade_body); render_migration_file(*,
version, description, ops, target_table) wraps them in the file
template; write_autogenerated_revision(*, version, description, ops,
target_table, out_path, overwrite=False) writes the file as UTF-8
with LF line endings. It raises FileExistsError when out_path
exists unless overwrite=True, and it does not create missing
directories. Every name, value and description is written as a Python
literal, so a generated file always compiles.
Generated migrations call ctx.<method>(target_table, ...) directly
– no helper = ctx.get_helper(target_table) boilerplate – because
MigrationContext exposes the column, partition, sort-order and
property helpers as direct methods. Generated files read like
hand-written ones.
What each op renders to:
Op |
|
|
|---|---|---|
|
|
|
|
|
|
|
|
raises (re-adding gives a new, empty column) |
|
|
raises (Iceberg only widens types) |
|
|
|
|
|
raises (the old source column and transform are not recorded) |
|
|
raises (the previous order is not recorded) |
|
|
restores the old value, or unsets a new key |
|
|
restores the old value; raises when it was not recorded |
“Raises” means the whole method raises NotImplementedError right
after target_table is assigned, before any ctx call, with
every generated step kept below as a comment, in order. So rolling
back a generated migration that drops a column fails before any of its
reversible steps run, and the version stays applied.
CLI usage¶
Given an existing sales.orders table with columns order_id
(long, required) and amount (double), and this target contract:
# contracts/orders.yaml
strict: true
columns:
- name: order_id
type: long
required: true
- name: order_amount
type: double
the command below writes the next migrations/NNNN_*.py file with
one ctx.enforce_rename(target_table, 'amount', 'order_amount')
step and its reverse:
iceberg-ops --namespace sales revision \
-m "align orders with v2 schema" \
--autogenerate \
--target-table orders \
--target-schema-yaml contracts/orders.yaml \
--rename-hint amount:order_amount
revision --autogenerate needs a catalog (--catalog,
--catalog-name or the config file) and --namespace to load the
existing table. These are group options, so they go before
revision. The migrations directory must already exist (iceberg-ops
init creates it); a migrations/<namespace>/ subdirectory is used
when present.
Flags:
--autogenerate / --no-autogenerate– enable diff mode.--target-table– existing table to compare against. Use a bare name; it is loaded from--namespacenow and written into the file as typed.--target-schema-yaml PATH– load aSchemaContractfrom YAML. Contracts are read as UTF-8 and validated (strict/requiredmust be booleans); types includedecimal(P,S),fixed[N]andtimestamp_ns(see Schema Migrations).--target-schema-arrow module:attr– import apa.Schema(the attr must resolve to apa.Schema). The module must be importable from the current directory or the environment.--rename-hint old:new– repeatable; tells the differ to emit a rename rather than drop + add. Hints are validated: an unknown column, an old name that is still in the target, a new name that already exists (swaps and chains), or two hints with the same name are usage errors (exit 2).
Plain iceberg-ops revision errors when any autogen flag is set
without --autogenerate so misuse is caught early. After writing the
file, the command lists the steps that make upgrade() or
downgrade() raise, the table columns a non-strict contract kept,
and nullability or doc differences it did not change. An invalid
contract or a bad hint exits 2 without writing a file.
Python API¶
import tempfile
from pathlib import Path
import pyarrow as pa
from iceberg_ops.autogen import schema_diff_to_ops, write_autogenerated_revision
from iceberg_ops.core.catalog import CatalogFactory
from iceberg_ops.runner import MigrationRunner
# An existing table to compare against.
catalog = CatalogFactory.build("memory")
catalog.create_namespace("sales")
existing_table = catalog.create_table(
"sales.orders",
pa.schema([pa.field("order_id", pa.int64(), nullable=False), ("amount", pa.float64())]),
)
target = pa.schema(
[
pa.field("order_id", pa.int64(), nullable=False),
pa.field("order_amount", pa.float64()), # was 'amount'
]
)
ops = schema_diff_to_ops(
current=existing_table.schema(),
target=target,
rename_hints={"amount": "order_amount"},
)
migrations_dir = Path(tempfile.mkdtemp()) / "migrations"
migrations_dir.mkdir()
write_autogenerated_revision(
version="0001",
description="rename amount -> order_amount",
ops=ops,
target_table="orders", # bare: follows the namespace at migrate time
out_path=migrations_dir / "0001_rename_amount.py",
)
runner = MigrationRunner(catalog, namespace="sales", migrations_dir=str(migrations_dir))
runner.migrate() # renames amount -> order_amount
runner.rollback() # renames it back
Limitations¶
Renames need an explicit
--rename-hint(rename_hints=in Python) – the differ has no other way to disambiguate a rename from a drop + add.Steps that cannot run as generated (a type change that is not a promotion, nested types) or cannot be reversed (column drop, type widening, partition-field removal, sort-order replacement, property removal without a recorded old value) make the affected method raise
NotImplementedErrorbefore any step runs. The generated calls stay in the file as comments for manual completion.Type changes are one-way. Only
int -> long,float -> doubleand decimal precision widening at the same scale are emitted; the generated downgrade of a type change always raises.Nested types (
Struct/List/Map) are not rendered: a nested add or type change blocks theupgrade()until you write that step by hand. With apa.Schematarget, nested columns that already exist are not compared (a warning is logged, and the CLI prints one naming them) and a new nested column is rejected withValueError.A generated
upgrade()is idempotent and adopts columns that already exist, butdowngrade()still removes every column the migration lists, even in a namespace whose table already had it. Reviewdowngrade()before rolling back a namespace whose table differed from the one that was diffed.Dropping a column that the partition spec, sort order or identifier fields reference makes
upgrade()raise before any step runs. The CLI passes the table’s spec, sort order and format version to the diff; from Python, passspec=,sort_order=andformat_version=toschema_diff_to_ops(identifier fields are always checked). A changed column whose name contains.raisesValueError, and so does, whenformat_versionis given, a type the table’s format version cannot hold.A non-strict contract (the default) keeps table columns it does not list; set
strict: trueto drop them.Nullability and doc changes on existing columns are reported, not emitted. New columns are added as optional and keep the target’s doc.
Each step commits on its own: if a step fails, the earlier steps stay applied and the version is recorded as
failed.spec_diff_to_opsrejects a partition field that keeps its name but changes its source column or transform, andsort_order_diff_to_opsrejects sort fields with a non-identity transform or a non-default null order (ValueError); write those steps by hand.The CLI diffs columns only; use the Python diff functions for partition specs, sort orders and properties.
See Autogenerate for the full API.