Schema Migrations¶
iceberg-ops takes the Alembic mental model – versioned NNNN_*.py
files driven by a MigrationContext – and adapts it to PyIceberg’s
metadata-only DDL primitives. Each file has an upgrade() and a
downgrade(); the runner applies pending files in version order and
records each version’s status in a history table inside your own
catalog.
Migration file conventions¶
Rule |
Detail |
|---|---|
File name |
|
Class name |
Must be exactly |
|
String equal to the file’s |
|
Human-readable description; surfaces in |
|
Must be idempotent (see Concurrent runs). Prefer
|
|
Reverses |
Table identifiers |
Pass bare table names ( |
Directory layout |
Flat ( |
Each ctx helper call is its own Iceberg commit, so a migration is
not atomic: if upgrade() fails half-way, the earlier steps stay
committed and the version is recorded as failed. A failed version
is pending again on the next migrate, which is another reason to
keep upgrade() idempotent.
MigrationContext API¶
The context is injected into every upgrade() / downgrade() call.
The core helpers are available as direct methods on the context;
ctx.get_helper(table) returns a SchemaEvolutionHelper for its
lower-level primitives.
Types: column helpers take a PyIceberg type
(pyiceberg.types.DoubleType(), StringType(), …) or a
primitive PyArrow type (pa.float64(), pa.string()), which is
translated to its Iceberg equivalent. Nested types (struct / list /
map) must be given as PyIceberg types. Table schemas take a
pyarrow.Schema or a pyiceberg.schema.Schema.
Table lifecycle
ctx.create_table_if_not_exists(identifier, schema, *, partition_spec=None, sort_order=None, properties=None, location=None)– create the table, or return the existing one unchanged. The keyword arguments apply only when the table is created. The parent namespace is created if needed (nested namespaces such as"sales.eu"work).partition_specandsort_orderreference source field ids, so pass them together with apyiceberg.schema.Schema; with apyarrow.Schema, create the table first and then callupdate_partition_spec/replace_sort_order.ctx.drop_table_if_exists(identifier)ctx.load_table(identifier)ctx.qualified(name)– returns"<namespace>.<name>"; a name that already contains a dot is returned unchanged.
Column lifecycle
ctx.add_column(identifier, name, field_type, *, doc=None)– add a new column. Raises if it already exists. Dotted names ("address.zip_code") add a field to an existing struct.ctx.enforce_column(identifier, name, field_type, *, doc=None, contract=None, legacy_archival=False)– ensure the column exists with the given type; see the Behaviour matrix below.ctx.enforce_rename(identifier, old_name, new_name, *, doc=None, contract=None)– rename idempotently. Both names must share a parent struct. When both names exist, the blocking column is archived first (see Rename collisions).ctx.delete_column(identifier, column_name)– metadata-only delete. Data files still hold the values (reachable by time travel).
Partition spec evolution
ctx.update_partition_spec(identifier, add_fields=None, remove_fields=None)– add / remove partition fields in one commit.add_fieldsis a list of(source_column, transform, partition_name)tuples, wheretransformis a PyIceberg transform instance such asDayTransform();remove_fieldsis a list of partition field names. Writing to a table partitioned by a non-identity transform (bucket, truncate, year, month, day, hour) needspyiceberg-core:pip install "iceberg-ops[transforms]".
Sort order management
ctx.replace_sort_order(identifier, sort_fields=None)– replace the table’s sort order. Each entry is(column, "asc" | "desc")with an identity transform (ascsorts nulls first,descnulls last).Noneor an empty list clears the sort order.
Table properties
ctx.set_table_properties(identifier, properties)– set one or more Iceberg table properties in one commit.ctx.unset_table_properties(identifier, keys)– remove properties by key; missing keys are ignored.
Convenience accessors
ctx.set_compaction_target_size(identifier, target_mb)– setswrite.target-file-size-bytes(target_mbMiB), which PyIceberg’s writers use as their target file size. The iceberg-ops compaction helper does not read it; it takes an explicittarget_size_mb.ctx.set_snapshot_retention(identifier, *, min_snapshots=None, max_age_hours=None)– sets the standardhistory.expire.min-snapshots-to-keep/history.expire.max-snapshot-age-msproperties.iceberg_ops.maintenance.expire_snapshots()reads them when its own arguments are omitted. Do not use it on AWS S3 Tables, which manages snapshot retention itself; a warning is logged when the catalog is configured for S3 Tables.ctx.set_clustering_keys(identifier, columns, *, direction="asc")– sets the table sort order oncolumns(the same asreplace_sort_orderwith one direction for every column). It writes no other properties.
Lifecycle hooks¶
BaseMigration carries four optional hooks:
pre_upgrade() -> upgrade() -> post_upgrade()
pre_downgrade() -> downgrade() -> post_downgrade()
All four are no-ops by default. Error semantics:
Hook |
If it raises |
|---|---|
|
The migration is not applied: it is recorded as |
|
The migration is already recorded as applied – a warning is logged, nothing is rolled back. |
|
The rollback stops with an error ( |
|
The rollback is already recorded – a warning is logged. |
Example: pre_upgrade quality gate¶
# migrations/0010_add_session_duration.py
from pyiceberg.types import DoubleType
from iceberg_ops.migration import BaseMigration, MigrationContext
class Migration(BaseMigration):
version = "0010"
description = "Add session_duration with non-empty gate"
def pre_upgrade(self, ctx: MigrationContext) -> None:
tbl = ctx.load_table("events")
if tbl.current_snapshot() is None:
raise RuntimeError("Cannot migrate empty events table.")
def upgrade(self, ctx: MigrationContext) -> None:
ctx.enforce_column("events", "session_duration", DoubleType())
def downgrade(self, ctx: MigrationContext) -> None:
ctx.delete_column("events", "session_duration")
Downgrades¶
downgrade() undoes upgrade() where the prior state is known: a
column that upgrade() added can be deleted, a rename can be renamed
back, a property can be restored to its old value. Some steps cannot
be undone:
a column delete (re-adding it creates a new, empty column; the old values are reachable only by time travel);
a type promotion such as
int -> long(Iceberg only widens types);a legacy-archival type change (see below).
For those, raise NotImplementedError as the first statement of
downgrade(), before any reversible step runs, so a rollback leaves
the table untouched. The runner re-raises NotImplementedError
(the CLI exits 1 with one Error: line), the version stays applied,
and the rollback stops; versions already rolled back earlier in the
same run stay rolled back. Files written by revision --autogenerate
follow this rule (see Autogenerate).
Before any downgrade() runs, rollback checks that the
migration file of every version it has to undo exists; if one is
missing, nothing is rolled back. rollback(target="0000") (CLI:
rollback --target 0000) rolls back every applied migration.
Legacy archival on incompatible type change¶
Iceberg’s spec only allows promoting primitive types in place
(int -> long, float -> double, decimal precision widening
at the same scale). Anything else – int -> string,
string -> long, string -> binary, a decimal scale change,
timestamp -> date – is not a promotion. enforce_column checks
this allow-list itself rather than relying on PyIceberg, which accepts
some changes the spec forbids. Because Iceberg has no atomic “drop and
re-add a column with the same name” primitive, the helper implements
an archive-and-recreate flow guarded by a compensating
transaction.
Opting in is explicit: pass legacy_archival=True to
ctx.enforce_column. By default an incompatible type change raises
pyiceberg.exceptions.ValidationError and the table is left
untouched, so the migration author sees the conflict and decides what
to do (cast in a backfill, write a data migration, drop the column,
etc.).
Behaviour matrix¶
Starting state |
Action |
|---|---|
Column absent |
Add a new column with the requested type. |
Column present, equivalent type (including an identical struct / list / map) |
No-op; only a changed |
Column present, promotable primitive type (e.g.
|
In-place |
Column present, incompatible type, |
Raise |
Column present, incompatible type, |
Step 1: rename the existing column to |
Archival is refused with ValueError (table untouched) for a
column that the partition spec, the sort order or the identifier
fields reference, because the archived column keeps its field id and
those references would silently move to it. Archival runs only on a
genuine type incompatibility: commit errors and failing contracts
propagate without archiving anything.
The two steps are separate commits. If the process dies between them,
the column exists only as <name>__legacy_N; re-running
enforce_column then sees the column as absent and adds the fresh
one. The new column starts empty: existing rows read it as null until
you backfill it.
Rename collisions¶
ctx.enforce_rename(table, old, new) uses the same archival path
when both old and new exist: the blocking new column is
renamed to <new>__legacy_N (and made optional), then old is
renamed to new and the archived column gets an [ARCHIVED] doc.
The same layout check applies (ValueError if the partition spec,
sort order or identifier fields reference the blocking column). If the
rename fails, the archive is renamed back (compensating transaction)
and the error is re-raised.
The __legacy_N suffix¶
N starts at 1 and increments. Every archival call scans the
current schema (or, for nested fields, the current parent struct’s
children) for existing <leaf>__legacy_<int> siblings and picks
max(N) + 1. A column that has been re-archived three times will
therefore have siblings v__legacy_1, v__legacy_2, and
v__legacy_3 all coexisting in the schema.
Nested struct fields use dotted paths (address.zip_code); the
archival is scoped to the parent struct, so the archived sibling is
address.zip_code__legacy_1 and never collides with a top-level
zip_code column or a sibling struct’s zip_code.
Example¶
# migrations/0042_retype_duration.py
import pyarrow as pa
from iceberg_ops.migration import BaseMigration, MigrationContext
class Migration(BaseMigration):
version = "0042"
description = "Re-type events.duration from int to string"
def upgrade(self, ctx: MigrationContext) -> None:
# int -> string is not a promotion. Opt in to archival so the
# historical int values are preserved as duration__legacy_1
# while a fresh string column takes the canonical name.
ctx.enforce_column(
"events",
"duration",
pa.string(),
legacy_archival=True,
)
def downgrade(self, ctx: MigrationContext) -> None:
# Raised before any change: the archived column carries the
# original data; rename it back by hand if needed.
raise NotImplementedError(
"Reversing a legacy_archival type change requires a manual "
"rename of duration__legacy_1 back to duration."
)
After upgrade runs, the schema contains both duration
(string) and duration__legacy_1 (int). The legacy column’s doc
carries the [ARCHIVED] prefix so it is obvious at a glance which
fields are historical.
Interaction with autogenerate¶
iceberg-ops revision --autogenerate (see Autogenerate)
emits a type change only when it is an Iceberg promotion
(ctx.enforce_column(target_table, "<name>", <wider type>)), and
never adds legacy_archival=True. For any other type change the
generated upgrade() raises NotImplementedError before any step
runs, with the generated calls kept as comments. The generated
downgrade() of any type change raises as well, because a type
cannot be narrowed back. To finish such a file, either:
write the step with
legacy_archival=Trueto opt into the archive-and-recreate flow, orreplace it with a hand-written data migration (see Cross-Table Data Migration) that copies the old values into a new column before dropping the original.
Migration history¶
The runner records history in the _iceberg_ops.migration_history
Iceberg table of the same catalog (the meta-namespace is
configurable). It holds one row per (version, namespace) with the
latest status (applied, rolled_back or failed, plus the
error text for a failure). Earlier states are not kept as rows; they
stay readable by time travel on the history table until its snapshots
expire. plan, status and history only read the catalog: they
do not create the namespace or the table, which are created on the
first write. plan and status still import every migration file
(see Trust model).
stamp records versions as applied without running upgrade(),
to adopt iceberg-ops on tables that already exist. It does not check
that the live schema matches what the stamped migrations would have
produced.
Concurrent runs¶
Concurrent runs are not guarded. Two migrate runs for one
namespace can both execute upgrade() for the same version; the
second run to record history raises
iceberg_ops.history.ConcurrentMigrationError (also importable from
iceberg_ops.runner), and history keeps one row per
(version, namespace) instead of a duplicate. That run’s
upgrade() may already have executed, so upgrade() must be
idempotent.
When a migration’s own commit conflicts with a concurrent commit, the
runner raises ConcurrentMigrationError and records nothing for
that version, so the other run’s history row is not overwritten.
Run migrations for a namespace from one job at a time (for example a CI concurrency group), or put an external lock in front of the runner. Runners for different namespaces can share the history table.
Trust model¶
Migration files are Python code, and the runner executes them.
status, current, plan, migrate, rollback and
stamp (and the matching MigrationRunner methods) import every
NNNN_*.py file in the migrations directory, or in its
<namespace>/ subdirectory when that exists, before they print or
change anything. Module-level code in those files runs with the
caller’s catalog credentials, so plan and status write nothing
to the catalog but are not safe to run on files you have not reviewed.
history reads the history table without importing the files.
revision --target-schema-arrow module:attr imports the module it
names, looking in the current directory first.
Review migration files, the migrations_dir setting of a cloned
project’s iceberg-ops.yaml, and modules named by
--target-schema-arrow as you would any other code. Do not run these
commands on an untrusted branch with production credentials, for
example in a CI job triggered by pull_request_target.
Running migrations from Python¶
The CLI (see CLI Reference) wraps MigrationRunner. The same flow from
Python, against a throwaway in-memory catalog:
import tempfile
from pathlib import Path
from iceberg_ops.core.catalog import CatalogFactory
from iceberg_ops.runner import MigrationRunner
migrations_dir = Path(tempfile.mkdtemp()) / "migrations"
migrations_dir.mkdir()
(migrations_dir / "0001_create_orders.py").write_text(
'''
import pyarrow as pa
from iceberg_ops.migration import BaseMigration, MigrationContext
class Migration(BaseMigration):
version = "0001"
description = "Create orders table"
def upgrade(self, ctx: MigrationContext) -> None:
ctx.create_table_if_not_exists(
"orders",
pa.schema([pa.field("order_id", pa.int64(), nullable=False)]),
)
def downgrade(self, ctx: MigrationContext) -> None:
ctx.drop_table_if_exists("orders")
''',
encoding="utf-8",
)
catalog = CatalogFactory.build("memory") # use your own catalog in practice
runner = MigrationRunner(catalog, namespace="sales", migrations_dir=str(migrations_dir))
runner.plan() # writes nothing to the catalog, but imports every migration file
runner.migrate() # applies 0001 to sales.orders
runner.status()
runner.rollback(steps=1) # runs 0001's downgrade()
Schema contracts¶
Schema contracts encode “this is what the table must look like” and verify the live schema matches. They are useful as a CI gate or as the target of an autogenerate run (see Autogenerate).
Python API:
import pyarrow as pa
from pyiceberg import types as ice_types
from iceberg_ops.core.catalog import CatalogFactory
from iceberg_ops.core.schema import ColumnSpec, SchemaContract
catalog = CatalogFactory.build("memory")
catalog.create_namespace("sales")
table = catalog.create_table(
"sales.orders",
pa.schema(
[
pa.field("order_id", pa.int64(), nullable=False),
pa.field("order_total", pa.float64()),
]
),
)
contract = SchemaContract(
columns=[
ColumnSpec("order_id", ice_types.LongType(), required=True),
ColumnSpec("order_total", ice_types.DoubleType()),
],
strict=False,
)
contract.enforce(table) # raises SchemaContractViolation on drift
required=True means the column must be present; it does not check
nullability. Types are compared exactly, so a column that was promoted
(int -> long) is reported if the contract still names the old
type. strict=True also reports columns the contract does not list.
YAML contracts (PyYAML is a core dependency):
# contracts/orders.yaml
strict: false
columns:
- name: order_id
type: long
required: true
- name: order_total
type: double
- name: created_at
type: timestamptz
# illustrative: needs contracts/orders.yaml and a loaded table
from iceberg_ops.core.schema import SchemaContract
contract = SchemaContract.from_yaml("contracts/orders.yaml")
contract.enforce(table)
The file is read as UTF-8 and validated: columns must be a
non-empty list with unique names, and strict / required must
be booleans. Supported types (case-insensitive): boolean, int
(or integer), long, float, double, string,
binary, date, time, timestamp, timestamptz,
uuid, decimal(P,S), fixed[N], and the other Iceberg
primitive type strings such as timestamp_ns. Nested types are not
supported.
See Migration Context, Migration Runner and Schema Helpers for the full reference.