Migrations reference¶
This page lists the facts the schema-management guides link to: the headers a step file can carry, the class of each step, what each door covers, what auto-migrate does on each backend, and what a PassReport holds. A generated index step shows a header at work:
-- 0002_author_nickname/02_idx_author_nickname.up.postgres.sql
-- ferro: no-transaction
DROP INDEX CONCURRENTLY IF EXISTS "idx_author_nickname";
CREATE INDEX CONCURRENTLY "idx_author_nickname" ON "author" ("nickname");
The no-transaction header tells the runner to open no transaction around the step, because Postgres refuses CREATE INDEX CONCURRENTLY inside one. The Migrations guide walks through the files a migration holds.
Step file headers¶
A step file may open with -- ferro: … lines. The generator writes them, except expand and irreversible, which only a person writes.
| Header | Meaning |
|---|---|
-- ferro: destructive |
The step discards data: it drops a column, a table or an enum type. Review is the gate; nothing refuses it. |
-- ferro: data-dependent |
The step discards nothing but fails on rows that do not satisfy it: a NOT NULL, a unique over existing rows, a type cast, the validation of a staged constraint, a removed enum label. |
-- ferro: no-transaction |
The runner opens no transaction around the step, so a statement such as CREATE INDEX CONCURRENTLY can run. The step is written to be safe to re-run from its first statement. |
-- ferro: foreign-keys-off |
A SQLite table rebuild: the runner turns foreign-key enforcement off while the step runs. |
-- ferro: not-applicable |
This dialect has nothing to do at this step number. |
-- ferro: expand |
In a hand-written SQL step: its author declares it expand, so the previous release's writes keep succeeding beside it (see step classes). A hand-written step without it is contract. The generator never writes it: a generated step's class comes from what the step does. |
-- ferro: nothing-to-reverse <reason> |
In a .down file: reverting removes the step's record and runs nothing. |
-- ferro: irreversible <reason> |
In a .down file: a run that would revert this step refuses before reverting anything. Only a person declares one. |
Headers an older release does not know
A ferro release refuses a step file that carries a header it does not know, naming the file. A file with -- ferro: expand is refused by a release that predates the header.
Step classes¶
Each step is expand or contract, decided from what the step does and recorded with the step when it is applied. require_applied(allow_ahead="expand-only") lets an older release start on a database that is ahead of it only by expand steps; the two-release contract shows it in a deploy.
| Expand | Contract |
|---|---|
| adding a table, or a nullable column | dropping a table or a column |
DROP NOT NULL |
SET NOT NULL: a required column's add-constraint and contract steps |
| a plain index; dropping an index, a check, a foreign key or a unique | a unique index; a type change |
| adding an enum label | renaming a column, a table, an enum type or a label; removing a label |
| a data step, a guard step | adding a check or a foreign key (NOT VALID) and its validate step |
a hand-written SQL step whose file says -- ferro: expand |
any other hand-written SQL step |
- A SQLite table rebuild takes the class of what it carries.
- A step that runs nothing on a dialect (
not-applicable) is expand there. - A generated step you edit by hand is classed like a hand-written one: contract unless its file says
-- ferro: expand.
What each door covers¶
The three doors are auto-migrate (here its update rung, migrate_updates), migrations and the Alembic bridge.
| Change | Auto-update | Migrations | Alembic bridge |
|---|---|---|---|
| Add a table, a nullable column, an index, an enum label | ✅ | ✅ | ✅ |
| Add a check, a foreign key, change a type or nullability, on Postgres | ✅ | ✅ | ✅ |
| The same on SQLite (needs a table rebuild) | ⚠️ warns, no DDL | ✅ generated table rebuild | ❌ refused at autogenerate |
Rename a column (renamed_from) or an enum label (__ferro_renamed_labels__) |
✅ | ✅ declared on the model | ✅ same hints |
Rename a table (__ferro_renamed_from__) |
✅ ALTER TABLE … RENAME TO under migrate_updates, indexes and checks renamed with it; without migrate_updates the pass warns and creates nothing |
✅ | ✅ |
| Drop a column | with migrate_destructive |
✅ marked -- ferro: destructive |
✅ marked # ferro: destructive |
| Drop a table | ❌ | ✅ marked -- ferro: destructive |
✅ marked # ferro: destructive |
| Add a new required column to a table with rows | only with a literal default (backfills existing rows) | ✅ expand, backfill, contract generated | Postgres: ⚠️ the plain op, marked # ferro: data-dependent; the backfill is yours to write. SQLite: ❌ refused at autogenerate |
| Make a nullable column required | Postgres: SET NOT NULL, fails if any row is NULL (write it as a migration with a backfill); SQLite: warns, no DDL |
✅ expand, backfill, contract generated | Postgres: ⚠️ the plain op, marked # ferro: data-dependent. SQLite: ❌ refused at autogenerate |
| Remove an enum label rows may hold | warns, no DDL | ✅ backfill and contract generated | ❌ not written; migrations generate it |
| Python data steps over the models as they were | ❌ | ✅ data steps | hand-written op.execute(...) |
| Change a primary key | ❌ | refused, with the recipe | ❌ refused, with the same recipe |
Auto-migrate changes per backend¶
What connect(..., migrate_updates=True) and ferro.migrate() do to an existing table, change by change. migrate_destructive=True adds the drops marked so.
| Change | SQLite | PostgreSQL |
|---|---|---|
| Add missing column | ✅ ADD COLUMN |
✅ ADD COLUMN |
Rename a column declared renamed_from |
✅ RENAME COLUMN |
✅ RENAME COLUMN |
Add the column's index (index=True) |
✅ CREATE INDEX |
✅ CREATE INDEX |
Add composite index (__ferro_composite_indexes__) to existing columns |
✅ CREATE INDEX |
✅ CREATE INDEX |
Add table check (__ferro_checks__) on CREATE TABLE |
✅ inline CHECK | ✅ inline CHECK |
| Add table check to existing table | ⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ ADD CONSTRAINT |
| Rebuild table check on body drift | ⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ rebuild: DROP CONSTRAINT + ADD CONSTRAINT |
Add column db_check=True on existing column |
⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ ADD CONSTRAINT |
Leftover ferro check (ck_* live, removed from model) under migrate_updates |
⚠️ UserWarning, constraint stays |
⚠️ UserWarning, constraint stays |
Drop orphaned ferro check (ck_*) |
⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ with migrate_destructive=True |
Add unique column (unique=True) |
✅ via explicit unique index + warning | ✅ inline UNIQUE |
| Add foreign-key column | ✅ column only, no FK constraint + warning | ✅ column + FK constraint |
| Add missing FK constraint to an existing column | ⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ ADD CONSTRAINT |
Drop a ferro foreign key (fk_*) from a column the model keeps (team: Annotated[Team, ForeignKey(...)] became team_id: int) |
⚠️ UserWarning, constraint stays; migrations generate the table rebuild |
✅ with migrate_destructive=True: DROP CONSTRAINT |
Change a foreign key's on_delete (or target) |
⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ rebuild: DROP CONSTRAINT + ADD CONSTRAINT |
| Change column type | ⚠️ UserWarning, no DDL (SQLite type affinity makes drift mostly cosmetic); migrations generate the table rebuild |
✅ ALTER COLUMN ... TYPE ... USING cast |
| Change nullability | ⚠️ UserWarning, no DDL; migrations generate the table rebuild |
✅ SET NOT NULL / DROP NOT NULL. SET NOT NULL backfills nothing, whatever the default: it fails the connect if any row holds NULL. A migration writes the backfill first |
| A server default no model declares (a foreign default, see destructive drops) | ⚠️ UserWarning, default stays; only a hand rebuild drops it (an expression, or any default on a nullable column; a literal on a NOT NULL column is ferro's own) |
⚠️ UserWarning under migrate_updates; ✅ with migrate_destructive=True: DROP DEFAULT |
Drop orphaned Ferro-named index (idx_* / uq_*) |
✅ with migrate_destructive=True |
✅ with migrate_destructive=True |
Redefine an index that keeps its name (a long name cut to 63 characters, two column groups that join to one name, or a live idx_* / uq_* index written another way) |
✅ DROP INDEX + CREATE INDEX under migrate_updates. A unique one over duplicate values fails the connect, counting them |
✅ same, in the table's transaction |
Add a missing enum label (a StrEnum grew a member) |
✅ nothing to do: enums store as text | ✅ ALTER TYPE ... ADD VALUE |
Rename an enum label declared with __ferro_renamed_labels__ |
✅ nothing to do | ✅ ALTER TYPE ... RENAME VALUE |
| Remove an enum label | ✅ nothing to do | ⚠️ UserWarning, no DDL. Migrations generate it with its backfill |
Inline single-column UNIQUE on an existing column, index option changes |
❌ never here; migrations generate it | ❌ never here; migrations generate it |
| Rename a table | ✅ ALTER TABLE … RENAME TO under migrate_updates when the database holds the old name from __ferro_renamed_from__ and not the new one; derived index and check names follow. Without migrate_updates a UserWarning names the table, the hint and both doors, and nothing is created. Migrations are the reviewed path |
✅ same |
| Drop a column | ✅ with migrate_destructive=True |
✅ with migrate_destructive=True |
| Drop a table | ❌ never here; migrations generate it, marked destructive | ❌ same |
| Change a primary key | ❌ never; no door generates it. Migrations refuse it with the recipe | ❌ same |
PassReport contents¶
ferro.migrate() and ferro.create_tables() return a PassReport: every statement the pass executed, in order, as an ExecutedStatement, and every warning it raised, as a Report. On-demand migrate() shows one read in code.
An ExecutedStatement's role says what the statement was for:
role |
The statement |
|---|---|
"schema" |
DDL: the create pass, the enum type statements, the reconciliation. |
"lock_timeout" |
The SET LOCAL lock_timeout, SET and RESET lines that bound each statement's wait for its table lock on Postgres. |
"probe" |
The SQLite query that looks for rows still holding a label the model renamed. |
A Report's kind names the warning, so code can match on it rather than on the sentence. subject says what it is about (the modelset, a table, a column or an enum type), and recurs is True when the warning comes back on every pass until someone acts.
kind |
Raised when |
|---|---|
HintRefused |
A declared rename hint cannot be meant; nothing is renamed. |
LeftoverChecks |
Live ferro-owned checks (ck_*) the model no longer declares. |
ExtraEnumLabels |
Live labels of an enum type the model no longer declares; the labels stay. |
ForeignFkDrift |
A foreign key drifts from the model under a name ferro does not own; it is left untouched. |
DroppedRowSecurity |
The live table carries row security the model no longer asks for; it stays. |
ForeignPolicies |
Live policies ferro does not own; they are never touched. |
UnverifiablePolicy |
A raw policy body ferro cannot verify; it is left as it is. |
PolicyBodyReplaced |
A raw policy was rebuilt for its metadata, replacing its live body. |
RowSecurityTeardown |
migrate_destructive tore down row security on a table, naming the policies dropped. |
ExtraPolicies |
Live ferro-owned policies the model no longer declares; they stay. |
ForeignDefault |
A live server default no model declares; it stays until migrate_destructive or a hand rebuild drops it. |
RefusedConversion |
A storage conversion that could reinterpret or destroy stored values; it is not executed. |
SqliteInPlace |
A change SQLite cannot make to an existing table in place: it needs the table rebuild only a migration writes. |
PrimaryKeyKept |
The declared primary key differs from the live one; no door changes a primary key in place. |
EnumTypeMove |
A column moves to or from a native Postgres enum type, which no statement converts in place. |
RowSecuritySkipped |
Row security a table declares that the database does not apply: SQLite has none, or the create pass met the table already there. |
PendingTableRename |
A table rename hint is live, and a pass without migrate_updates holds the table back. |
StrandedLabelRename |
A SQLite column whose rows still hold a label the model renamed. |
RowSecurityUnderMigrator |
The migrating role is itself bound by the FORCE policies it maintains. |
RunLockWait |
The pass waited for another run's lock. |
DdlLockRetry |
A statement timed out waiting for its table lock and is retried. |
Every warning is also raised as a UserWarning. The API reference documents each field.
Exit codes¶
Every ferro migrate verb shares one set of exit codes; the CLI reference lists them.