sustained.migrations, sustained.aio_migrations, sustained.autogenerate, sustained.migration_files, sustained.ddl, and sustained.analysis. Import these names from their module path, because the package root does not re-export them; the ddl module itself imports as from sustained import ddl.
Guide: Schema and Migrations.
Migration
Migration(id, up, down=..., checksum=None, repeatable=False, transactional=True)
A Migration is one schema change, and each of its steps is a SQL string, a list of statements, a ddl step, or a callable that receives the connection. A list may mix strings and ddl steps.
| Attribute | Type | Meaning |
|---|---|---|
id |
str |
Fixes the order. Must not be empty. |
up |
step | What the migration does. |
down |
step or None |
What reverses it. None means it cannot be reverted. |
checksum |
str or None |
Pins a checksum. Needed only for a callable step, which has no SQL to hash. |
repeatable |
bool |
Re-runs whenever its checksum changes, instead of running once. |
transactional |
bool |
False runs the migration outside a transaction, for a statement the engine refuses inside one. |
When down is not given and up is a list of reversible ddl steps, Sustained derives the down step from the inverses of the up steps, newest first. If the up step includes an irreversible ddl step instead, Migration raises ValueError and names that step, so pass an explicit down step, or down=None to declare the migration irreversible. An up step with no ddl steps in it derives nothing, and down stays None. Repeatables never derive a down step.
transactional=False covers the up step and the down step. The migrator turns the driver’s own transaction control off for the migration, so a statement such as CREATE INDEX CONCURRENTLY on Postgres can run, and turns it back on afterwards. The migrator writes the tracking row after the statements. Nothing rolls back a failed migration of this kind, so the statements that already ran stay applied, and the failure row makes validation stop the next up() until you clean up and run repair(). AsyncMigrator also runs such a migration outside a transaction, but an adapter over a driver with its own transaction control, such as DbApiAsyncAdapter, still opens one, so use AsyncpgAdapter there.
Migration raises ValueError when the id is empty, when a repeatable declares a down step, and when a repeatable has a callable step and no explicit checksum.
Typed ddl steps
These names live in sustained.ddl. A DdlStep names one schema change and renders to SQL through a dialect compiler when the migration runs, so one migration serves every dialect. Build steps with the factories below, not with DdlStep directly.
| Signature | Reverses as |
|---|---|
create_table(model_or_name, columns=None, constraints=None, options=None, indexes=None) |
drop_table, dropping the columns’ enum types after the table |
drop_table(model_or_name) |
irreversible |
add_column(table, name, column) |
drop_column |
drop_column(table, name) |
irreversible |
rename_column(table, old, new) |
the rename, backwards |
rename_table(old, new) |
the rename, backwards |
add_foreign_key(table, foreign_key) |
drop_foreign_key |
drop_foreign_key(table, name) |
irreversible |
add_check(table, check) |
drop_constraint |
drop_constraint(table, name) |
irreversible |
create_index(table, index) |
drop_index |
drop_index(table, name) |
irreversible |
create_enum(name, *values) |
drop_enum |
drop_enum(name) |
irreversible |
add_enum_value(name, value) |
irreversible: Postgres has no DROP VALUE |
set_column_comment(table, name, comment, previous=None, column=None) |
the same step with previous, when given |
sql(text) |
irreversible: one raw statement, rendered as written on every dialect |
A table argument takes a Model class or a table name string. create_table(model) reads the model’s columns, constraints, options, and indexes when the step is built, so a later model edit changes the migration’s checksum; pass explicit columns when the migration must outlive the model. The column, check, foreign_key, and index arguments take the same ColumnDef, Check, ForeignKey, and Index objects a model declares.
On a DdlStep, render(compiler) returns the SQL statements for that compiler’s dialect, reversible says whether the step knows its inverse, inverse() returns that step or None, and signature() returns the canonical form the checksum hashes: the operation name and its arguments, serialized the same way on every dialect.
create_enum, drop_enum, and add_enum_value raise DialectError at render time on a dialect without named enum types. Each factory raises ValueError for a missing name or an empty argument.
set_column_comment sets one column’s comment, and comment=None clears it. Pass previous as the column’s current comment, None when it has none, and the step reverses by setting it back. MySQL changes a comment by restating the whole column with MODIFY COLUMN, so there the step also needs the column’s ColumnDef as column. The restatement includes the type, nullability, default, and identity, and never restates UNIQUE, which would add a second index on each run. Dialects that store no column comments raise DialectError at render time, and Athena raises because it cannot change a comment in place.
Guide: Typed migration steps.
Migrator
Migrator(connection, migrations, table='sustained_migrations', dialect=Dialects.DEFAULT, tracking_table_options=None, rehearsal_table='sustained_rehearsals', guards=None, callbacks=None)
Migrator applies and reverts migrations and records them in a tracking table. It runs each migration inside a transaction on engines whose schema changes roll back, which excludes MySQL and Athena, unless the migration sets transactional=False. Duplicate ids raise ValueError.
guards is a list of rules over the statements an up run would apply. See Guards below. callbacks is a Callbacks object, whose functions up() calls around the run.
Migrator exposes connection, dialect, and compiler as properties, and renders ddl steps through that compiler.
Inspecting
applied_records() -> list[AppliedRecord]
Every tracking row, ordered by sequence. Creates or upgrades the tracking table first.
applied() -> list[str]
The ids that ran successfully, in order.
pending() -> list[Migration]
Versioned migrations with no successful row, then repeatables that are new or changed.
status() -> list[tuple[str, bool]]
(id, applied) per registered migration.
statuses() -> list[tuple[str, str]]
(id, state), where state is applied, pending, or changed. changed marks a repeatable whose contents differ from its last run.
impact(models=None, assert_algorithm=False, exact_counts=False, live=False, older_than=60.0, online=False, *, allow_drops=False, ...) -> ImpactReport
preflight(models=None, older_than=60.0, exact_counts=False, online=False, *, allow_drops=False, ...) -> Preflight
The impact of the run up() would make on a live database: the locks each statement takes, what they block, the work each does, and findings with safer forms. With models, the migrations they generate are analyzed after the pending ones, and online splits them as plan_migrations() does. Both take the keywords allow_drops, ignore_changed_columns, migration_id, renames, table_renames, and type_casts, the diff options up() takes, so the same options analyze the migration up() would generate. The diff reads the schema as it is now, before the pending migrations apply, where up() diffs after them; sustained impact diffs after them on a scratch database. The analysis writes nothing. exact_counts=True counts the rows of each SQLite table sqlite_stat1 has no row count for, and reads each table’s bytes from dbstat. live=True adds the report’s preflight: the sessions each statement would wait behind now, and the transactions open older_than seconds or longer. preflight() returns that read alone. Raises DialectError on a dialect the analysis does not cover, which is Presto and Athena, and with live=True, or from preflight(), on SQLite and DuckDB too. With live=True, or from preflight(), an older_than that is negative, NaN, or not a number raises ValueError before anything is read. See Impact reference and Live preflight.
pending(), status(), statuses(), and validate() read the tracking table without creating or upgrading it, on Migrator and AsyncMigrator alike, so they run on a read-only replica. A database with no tracking table reads as one with nothing applied.
Running
up(target=None, validate=True, allow_out_of_order=False, models=None, unrehearsed=False, preflight=None, online=False, ...) -> list[str]
Validates, then applies pending migrations in order. target stops after that id and skips the repeatables. With models, the diff against them runs after the versioned migrations and before the repeatables, and you cannot combine models with target. unrehearsed=True waives the rehearsal gate below. exact_counts=True passes on to the server facts the guards read, as impact() takes it. Those facts include the sizes of the tables the run’s statements name, and of no other table. preflight='warn' prints, after the guards pass, the sessions the run would wait behind on stderr, and preflight='refuse' raises PreflightBlocked when there is one. preflight=PreflightCheck(mode, older_than) also sets the age from which an open transaction is printed; see Live preflight. A preflight on a dialect without one, SQLite and DuckDB among them, raises DialectError before the run starts. The remaining options are the diff options below.
down(steps=1) -> list[str]
Reverts newest first, and never touches repeatables. A failed attempt on record makes down raise MigrationError before it reverts anything. A down step that fails where nothing rolls it back, on an engine without transactional DDL or in a migration with transactional=False, marks that migration’s row failed, so validation blocks the next run until you finish the revert and run repair().
down_to(target) -> list[str]
Reverts until target is the newest applied.
baseline(target) -> list[str]
Records migrations up to and including target as applied, without running them. Also records every repeatable at its current checksum. Raises MigrationError before it writes a row when a migration it would record has a failed attempt on record, so run repair() first. A write that fails part way rolls back every row the call inserted.
up raises MigrationError when validation finds problems, RehearsalRequired when the run would remove data and no passing rehearsal row covers it, PreflightBlocked under preflight='refuse' when another session has a lock a statement would wait for, and ValueError for an unknown target or a target that names a repeatable. Every migration that applied before an error stays applied, and the exception lists their ids on its applied attribute. A driver error gets the attribute too, unless its class refuses new attributes. down and down_to raise ValueError when an applied migration is not registered, and when it has no down step.
A migration that fails re-raises the driver’s exception with a migration_id attribute attached, so the caller can tell which migration failed.
up, down, down_to, baseline, repair, and record_rehearsal commit their own work as they go. Each one raises ValueError before it sends a statement when a transaction() block is open on the connection, because that commit would also commit the caller’s uncommitted work. before_migrate does not fire for a refused up. On AsyncMigrator the same calls refuse an open async_transaction() block on the adapter.
Validating and repairing
validate(raise_on_problems=True) -> list[str]
Checks the tracking table against the registry. Raises MigrationError unless raise_on_problems=False.
repair() -> list[str]
Deletes failed-attempt rows and rewrites drifted or missing checksums. Returns the actions taken.
The problems validation reports:
- A migration has a failed attempt on record.
- An applied id is not registered with this migrator.
- A checksum no longer matches, which means the migration changed after it ran.
- A pending migration is ordered before an applied one.
allow_out_of_order=Trueaccepts that order.
repair() fixes the bookkeeping only. It does not undo the schema changes a failed attempt left behind, and it leaves repeatable checksums alone, because a changed checksum schedules the re-run.
Generating from models
plan(models, ...) -> Migration | None
The migration that up(models=[...]) would generate, or None when the schema is current. plan() records nothing and applies nothing.
plan() also takes snapshot, a schema that read_schema() returned, or on AsyncMigrator one that async_introspect_schema() returned. The plan then diffs that snapshot and does not read the schema again, and the snapshot is left unchanged. The plan command reads the schema once this way and diffs it twice: once with drops for the drift section, and once without them for the guards and the rehearsal check. The connection still answers the check for rows in a table that gets a new NOT NULL column.
plan_migrations(models, ..., online=False) -> list[Migration]
The migrations that up(models=[...]) would generate, as a list that is empty when the schema is current. It takes plan()’s arguments, snapshot included, and without online the list contains the one migration plan() returns.
read_schema(models) -> dict[str, IntrospectedTable]
Reads the schema that plan(models) compares against: the schema the connection is on, plus every schema the models name in tableSchema.
up(models=[...], ...) -> list[str]
Generates that migration, registers it, and applies it along with every other pending migration. With online=True, it generates and applies each migration plan_migrations(models, online=True) returns, in order.
drift(models, renames=None, table_renames=None) -> list[str]
What the models still ask for, one readable line each. Empty when the database contains everything they declare.
sync(models, ...) -> list[str]
Deprecated since 2.13.0, removed in 3.0. Warns, then calls up(models=...).
These methods take the same options:
| Option | Default | Meaning |
|---|---|---|
allow_drops |
False |
Generate drops for tables and columns the models do not declare. Without it, they are left alone. |
ignore_changed_columns |
False |
Skip type and nullability differences entirely. |
migration_id |
generated | The id. Defaults to auto_<UTC timestamp>. |
renames |
None |
{'table.old': 'new'}, so the diff reads a column rename as a rename rather than a drop plus an add. |
table_renames |
None |
{'old': 'new'}. |
type_casts |
None |
{'table.col': 'col::integer'}, a USING hint. Postgres only. |
ignore_undeclared |
True |
Leave objects the models do not declare alone. False refuses to generate while any exist. |
online |
False |
On PostgreSQL, split the work into <id>, which runs in one transaction and changes only the catalog, and <id>_online, which has transactional=False and runs the backfills, CREATE INDEX CONCURRENTLY IF NOT EXISTS, VALIDATE CONSTRAINT, the check route to SET NOT NULL, the drops, and last the drop of each check that route added, so a failed run runs again from its first statement after repair(). On MySQL and MariaDB, it turns on assert_algorithm. Other dialects ignore it. sync() does not take it. plan(online=True) returns the one migration the split generates, and raises ValueError when it generates two, which only plan_migrations() returns. See Online migrations. |
assert_algorithm |
False |
On MySQL and MariaDB, write the ALGORITHM and LOCK clause the impact rules predict on each generated ALTER TABLE, CREATE INDEX, and DROP INDEX whose prediction is INSTANT, NOCOPY, LOCK=NONE, or INPLACE, LOCK=NONE with confidence known, on a table that existed before the migration. The server facts are read after the diff. Changes the SQL text, so a rehearsal without it does not cover a run with it. See Asserting the algorithm. |
Pass every model you manage, because these methods compare the whole database against the whole list, and nothing keeps a table up to date when its model is missing from the list. The comparison always excludes the tracking table.
Rehearsing
rehearse(scratch=False, models=None, ..., trace=False, online=False, lock_timeout=None) -> Rehearsal
rehearse() applies every pending migration, runs the down steps back down, and rolls the whole run back. It returns an empty Rehearsal when nothing is pending. With models, the migration generated from those models joins the run without being registered, and the remaining arguments are the diff options above. With online=True on PostgreSQL, the statements of <id>_online run inside the rehearsal transaction with CONCURRENTLY removed, and a traced rehearsal leaves them untraced.
The rehearsal transaction runs on the database the connection points at, which is the live one unless scratch=True points it at a scratch database. Every lock a statement takes stays taken until the rollback at the end of the rehearsal, and a statement that waits for a lock waits without a limit while the sessions queued behind it wait too. lock_timeout=seconds sets the dialect’s lock timeout for the rehearsal, so a statement that waits longer fails the rehearsal instead: SET LOCAL lock_timeout on PostgreSQL, SET LOCK_TIMEOUT on SQL Server, lock_wait_timeout and innodb_lock_wait_timeout in whole seconds on MySQL and MariaDB, and PRAGMA busy_timeout on SQLite. The session’s earlier values are set again after the rollback. DuckDB ignores it, because a conflicting write there fails without waiting. A lock_timeout that is not a number above 0 raises ValueError. sustained rehearse passes the config module’s rehearsal_lock_timeout.
rehearse() reads the schema before the run and again after the down sweep, so it reports a down step that runs without taking its change back. The comparison covers tables and columns, but not indexes, constraints, or column defaults.
With trace=True, each statement of each up step runs on its own. On Postgres it runs between two reads of the locks granted to the transaction and the files of the tables it names. On SQL Server it runs between two reads of the locks, the partitions of the tables it names, and the log the transaction has written. On MySQL and MariaDB, an ALTER TABLE, CREATE INDEX, or DROP INDEX runs with each ALGORITHM and LOCK clause in turn until the server accepts one. The result’s impact is the run’s impact report with what the server did in place of what the rules predicted. See Observed impact. trace=True raises DialectError on any dialect other than Postgres, MySQL, and SQL Server, and needs scratch=True on MySQL and SQL Server.
rehearse() raises ValueError when:
- The dialect’s schema changes do not roll back. Only the default dialect, Postgres, and DuckDB qualify. Pass
scratch=Trueto waive the check for a connection to a database you can throw away. - The connection is in autocommit mode.
- The call sits inside an open
transaction()block, because the rollback would take the caller’s work back as well.
The check reads the declared dialect rather than the engine, so a config that leaves the dialect unset while it points at MySQL would rehearse for real.
Rehearsal rows
record_rehearsal(key, outcome='passed')
Writes the rehearsal row for one key, replacing any earlier row. outcome is 'passed', 'failed', or 'override' for statements applied with unrehearsed=True; anything else raises ValueError.
rehearsal_outcome(key) -> str | None
What the recorded rehearsal proved, or None when none covers the key.
rehearsed(key) -> bool
Whether a passing rehearsal covers the key.
run_outcome(applied, run) -> str | None
The outcome recorded for a run of the migrations in run, starting from the rows in applied, which is the lookup up() makes. It reads the row under rehearsal_key(applied, run) first. When no row has that key, it reads the row under the key a release before 2.25.0 wrote for the same run, so a rehearsal recorded before an upgrade still covers the run after it. That key hashes the older checksum format, which cannot tell a split statement from the original. Added in 2.25.0.
record_scratch_rehearsal(results) -> str | None
Writes the rows that a passing rehearse(scratch=True) proved onto the database this migrator is bound to, and returns the key of the full run. The keys come from this database’s applied history and pending set. They are the same keys a passing rehearse() on this database records, so a targeted up() reads a row too. It returns None and writes nothing when the rehearsal failed, when nothing is pending, or when the scratch run did not run every migration pending here. Added in 2.25.0.
A passing rehearse() records its own row and returns the key on the result. It also records a row for each shorter run a target would produce that removes data, and for each run without a target that could follow those targeted runs, because the rehearsal applied and reverted those statements on its way through. rehearse(scratch=True) records nothing, because the row belongs on the database the next run reads, so pass its result to record_scratch_rehearsal() on a migrator bound to that database.
up() reads a rehearsal row before it applies any statement that removes data, and raises RehearsalRequired when no row covers the content. A callable step renders no SQL, so it never triggers the check.
rehearsal_key(applied, run) -> str
rehearsal_key() computes the key both sides use: a SHA-256 over the checksums of the successful rows in applied, then over the checksums of the migrations in run. It hashes an id only for a callable step with no checksum, as the token id:<id>.
receipt_key() is a deprecated name for this function, and RECEIPT_PASSED, RECEIPT_FAILED, and RECEIPT_OVERRIDE are deprecated names for the outcome constants REHEARSAL_PASSED, REHEARSAL_FAILED, and REHEARSAL_OVERRIDE. These names have been deprecated since version 2.20.0. They still import from sustained.migrations and raise a DeprecationWarning, and version 3.0 removes them.
Rendering without running
script(direction='up', annotate=False, exact_counts=False) -> str
script() returns every statement a run would execute as text, including the tracking bookkeeping. Any direction other than up or down raises ValueError.
With annotate=True, the migration statements are analyzed as one run, as impact() analyzes them, with the server facts read from the connection. Each statement’s tables and findings print above it as -- impact: lines, each migration’s windows and findings after its last statement, and the report’s summary on the first line. A statement that locks no table reads -- impact: locks no table. The bookkeeping statements are not analyzed. exact_counts passes on to that read, as impact() takes it. On a dialect the analysis does not cover, annotate=True raises DialectError before anything is read.
Result types
AppliedRecord(id, seq, checksum, success, generated) is one tracking row. generated marks a row that a model diff wrote.
RehearsalResult(id, up_ok, down_ok, error, landed, reversed) records what a rehearsal proved about one migration. up_ok is None for a migration the rehearsal leaves out, which is one with transactional=False, because such a migration runs outside a transaction and the rehearsal cannot roll it back. down_ok is None when the rehearsal proved nothing, and error then says why: no down step, no down step (repeatable), down not reached: ..., or down not rehearsed: the run stopped.
landed and reversed are None when the check did not run, [] when the check passed, and a list of readable lines when the check failed. The rehearsal fills landed for the generated migration only, and fills reversed for every migration whose down step ran.
rehearse() returns a Rehearsal, which subclasses list over those results, so it iterates and indexes like a list. It adds the attributes below.
| Attribute | Type | Meaning |
|---|---|---|
key |
str |
The rehearsal key for the set the rehearsal ran. |
recorded |
bool |
Whether the row was written. False after scratch=True. |
ok |
bool |
Whether every result passed. |
impact |
ImpactReport or None |
The run’s observed impact, after trace=True. None otherwise. |
generated |
list[Migration] |
The migrations the diff against models generated after the pending migrations applied. [] without models. |
ok uses the module function rehearsal_failed(result). A result fails when its up step raised, when its down step failed, when the models did not land, or when the schema did not come back. A down step the rehearsal could not prove is not a failure.
The tracking table
The tracking table is named sustained_migrations by default and has these columns:
| Column | Type | Contains |
|---|---|---|
id |
VARCHAR(255) primary key |
The migration id |
seq |
INTEGER |
The apply order |
checksum |
VARCHAR(64) |
SHA-256 of the up statements |
applied_at |
TEXT not null |
When it ran |
execution_ms |
INTEGER |
How long it took. Null for a baselined row |
success |
BOOLEAN not null |
Whether it finished |
generated |
BOOLEAN |
Whether a model diff wrote it. Such a row is never reported as an unregistered migration |
steps |
TEXT |
The up and down statements of a generated migration, as JSON, with "transactional": false for one that runs outside a transaction. Null for every registered one, whose statements live in your code or your migrations directory |
On Athena the same columns are all plain and nullable, because Athena enforces no constraints. A tracking table written by an earlier version, with only id and applied_at, upgrades in place on first use. A generated row written before the steps column existed contains no statements, so down() cannot revert that row.
The rehearsal table
The rehearsal table is named sustained_rehearsals by default, is created on first use, and has these columns:
| Column | Type | Contains |
|---|---|---|
rehearsal_key |
VARCHAR(64) primary key |
The key rehearsal_key() computes |
outcome |
VARCHAR(16) not null |
passed, failed, or override |
rehearsed_at |
TEXT not null |
When the rehearsal ran |
Every diff against the models excludes both tables, so neither table reads as drift or as an object a down step left behind.
Module functions
migration_checksum(migration) -> str | None
The checksum validation compares. A ddl step hashes as its canonical signature, so the checksum is the same on every dialect. None for a callable step with no explicit checksum.
create_table_migration(model) -> Migration
A create/drop pair derived from a model.
migration_sql(migration, direction='up', compiler=None) -> list[str]
One migration’s statements, for offline review. Ddl steps render for the given compiler’s dialect, or ANSI when none is given. A callable step renders as a comment. Raises ValueError when that step is None.
rehearsal_failed(result) -> bool
Whether one result stops a rehearsal from passing.
run_statements(run, compiler=None) -> list[MigrationStatement]
Every up statement a run would apply, callable steps skipped. Ddl steps render for the given compiler’s dialect, which is how the guards read them. Each statement is a MigrationStatement naming the migration it came from.
check_guards(guards, run, dialect, reported=None)
Runs the guards over a run. Raises GuardBlocked on a blocking verdict, prints warnings on stderr. The statements have no impact attached, so an impact rule analyzes them with no server facts; up() attaches the impact first.
check_statements(guards, statements, dialect, reported=None)
What check_guards() does, over statements already collected, such as statements attach_impact() returned.
report_danger(statements, reported=None)
Prints each danger finding of the statements’ attached impacts on stderr, one line each: danger: <rule> <statement>: <message>. reported is a set of (rule, statement) pairs already printed, which it skips and adds to.
Callbacks
Callbacks(before_migrate=None, after_migrate=None, on_error=None)
Callbacks is a NamedTuple of optional functions that you pass to either migrator. before_migrate(connection) runs before validation and before the advisory lock of an up. after_migrate(connection, applied) runs only when at least one migration applied. on_error(connection, migration_id, error) runs after a failed up or down, and its migration_id argument is None when the run failed before it reached a migration. When on_error itself raises, its error prints on stderr, and the run’s error still propagates. A before_migrate or after_migrate that raises stops the caller.
Guards
Guards live in sustained.guards. A guard is a Callable[[Sequence[MigrationStatement], Dialects], list[Verdict]]. It reads the statements an up run would apply and returns one Verdict(rule, verdict, statement) per objection, where the verdict field is BLOCK ('block') or WARN ('warn'). A MigrationStatement (in sustained.analysis) is a str that also records migration_id, the migration the statement came from, and transactional, that migration’s transaction flag. A guard typed against Sequence[str] still fits the type and still runs, because the statements are strings. statement_scope(statement) returns the pair for one statement, and gives (None, True) for a plain string.
up() raises GuardBlocked on a blocking verdict, before any statement runs, and prints warnings on stderr. On a dialect the impact analysis covers, up() first reads the server facts, analyzes the run, and sets each statement’s impact. When no guard has a true reads_impact attribute, it then prints each danger finding on stderr after the guards pass. A callable step renders no SQL, so guards cannot read it. down() runs no guards, because a down step undoes work the rules already passed, and no_drops() would block every rollback of a create.
no_drops() -> Guard
Blocks a table, column, view, materialized view, schema, database, enum type, or constraint drop. Index and key drops pass.
index_must_be_concurrent() -> Guard
Blocks CREATE INDEX without CONCURRENTLY. Postgres only; silent elsewhere. CREATE INDEX ... ON ONLY passes, which 2.25.0 and earlier blocked: it builds no index, and Postgres refuses CONCURRENTLY in it. See The rules.
Postgres refuses CREATE INDEX CONCURRENTLY inside a transaction block, so the index needs a migration with transactional=False, or a SQL file with the -- sustained: no transaction marker.
no_table_rewrite() -> Guard
Warns on a column type change, or a NOT NULL with no default for existing rows.
no_lock_without_timeout() -> Guard
Blocks a statement that alters or drops a table with no SET lock_timeout in force before it. A timeout later in the run does not cover it. Postgres only; silent elsewhere.
A plain SET lock_timeout, with or without SESSION, covers the rest of the run. A SET LOCAL lock_timeout covers only the statements after it in its own migration, because the commit that ends the migration drops the setting. In a migration with transactional=False there is no transaction block in which a LOCAL setting could apply, so the rule ignores it, and you need the plain form there.
max_statements(limit) -> Guard
Blocks every statement past limit. A limit below 1 raises ValueError.
max_blocking(limit, over_rows=None, over_bytes=None, assume_small=False) -> Guard
Blocks a statement that blocks more than limit on a table past the thresholds. limit is a Blocks member or its name. With neither threshold every table counts. With either, a table counts when its estimated size passes one of them, and when the size the threshold reads is unknown, unless assume_small=True. A lock in the statement’s unnamed_locks, on a table no read named, counts as a table of unknown size. A statement whose impact has confidence unknown is blocked whatever the thresholds, since the analysis names no table for it. A negative threshold or an unknown limit raises ValueError. The verdict’s rule reads max_blocking(writes, over_rows=100000).
no_rewrite(over_rows=None, over_bytes=None, assume_small=False) -> Guard
Blocks a statement whose work on a table past the thresholds is rewrite or unknown, and a statement whose impact has confidence unknown. The thresholds read as they do for max_blocking().
lock_timeout_required() -> Guard
Blocks a statement with a <profile>.lock_timeout finding, such as pg.lock_timeout, or mysql.lock_timeout and mariadb.lock_timeout on MySQL and MariaDB: a lock that would queue other sessions with no lock timeout in scope. On PostgreSQL, timeout scopes read as they do for no_lock_without_timeout(). On MySQL and MariaDB, the timeout is lock_wait_timeout, and a SET of it in any session scope covers the rest of the run. A timeout the connection already has covers the run when up() read it. A statement whose impact has confidence unknown is blocked, since the analysis cannot say which locks it takes.
no_unknown_impact() -> Guard
Blocks a statement whose impact has confidence unknown, and nothing else. The other three rules block such a statement as well.
The four rules above are silent on a dialect the impact analysis does not cover.
reads_impact(guard) -> bool
Whether a guard has a true reads_impact attribute. The four impact rules set it. Set it on a guard of your own that reads statement.impact, and up() stops printing danger findings.
statement_impacts(statements, dialect) -> list[StatementImpact | None]
Each statement’s impact, in order: its impact attribute, or else what analyze() gives with no server facts, reading the statements as one run. Every entry is None on a dialect the analysis does not cover.
run_guards(guards, statements, dialect) -> list[Verdict]
Every guard’s verdicts, in guard order. run_guards first wraps each plain string in a MigrationStatement that names no migration, so every guard reads the same kind of value.
blocking(verdicts) -> list[Verdict]
The verdicts that stop a run.
warnings_only(verdicts) -> list[Verdict]
The verdicts that only report.
The scan is textual, like the scan behind the destructive labels. Sustained strips comments, empties quoted text, collapses whitespace, and parses no SQL. It reads comments and whitespace by the rules of the dialect the guard receives: MySQL # comments and the SQL inside /*! ... */, nested block comments on Postgres and SQL Server, and the engine’s whitespace, so CREATE INDEX CONCURRENTLY idx written with a no-break space (U+00A0) before idx blocks on Postgres, which reads CONCURRENTLY and idx as one index name. A guard blocks or warns when any reading the scan takes matches. The verdict prints the statement with its quoted text intact.
AsyncMigrator
AsyncMigrator lives in sustained.aio_migrations.
AsyncMigrator(adapter, migrations, table='sustained_migrations', dialect=Dialects.DEFAULT, tracking_table_options=None, rehearsal_table='sustained_rehearsals', guards=None, callbacks=None)
AsyncMigrator is the same runner on an AsyncAdapter: the same tracking table, the same Migration objects, and the same validation rules and refusal messages. Guards and callbacks work the same way, except that a callback receives the adapter, and is awaited when it returns an awaitable. AsyncMigrator exposes adapter, dialect, and compiler as properties.
Every method is a coroutine:
applied_recordsread_applied_recordsappliedread_appliedpendingstatusstatusesvalidaterepairbaselineuprehearsedowndown_torecord_rehearsalrecord_scratch_rehearsalrehearsal_outcomerehearsedrun_outcomescriptplandriftimpact
Both migrators compute the key the same way, so a row written by one migrator opens the gate for the other on the same database.
await migrator.script('up') renders the same text Migrator.script('up') renders, with annotate=True too, and writes nothing, not even the tracking table. read_applied_records() and read_applied() read the rows the same way.
await migrator.plan(models) and await migrator.drift(models) diff the models against the database and return what Migrator.plan() and Migrator.drift() return. Both read the schema through the adapter and write nothing. The schema read is the only statement plan() runs, and with snapshot it runs none, so it cannot ask whether a table contains rows. A table it cannot read counts as one that contains rows, so a new NOT NULL column with no default and no backfill is refused here even on an empty table, where Migrator.plan() adds it.
up(models=[...]) and rehearse(models=[...]) take the same arguments the synchronous ones take, and behave the same way: the generated migration runs last of the versioned ones, its statements go on its tracking row rather than into the migrations directory, and it joins the registered list only after it applied.
A callable step receives the adapter rather than a connection, and its return value is awaited when it is awaitable.
Migrations as SQL files
These names live in sustained.migration_files.
load_migrations(directory, placeholders=None) -> list[Migration]
load_migrations reads the <id>.up.sql files first, each one optionally paired with <id>.down.sql, sorted by id. Then it reads the <id>.repeat.sql repeatables, also sorted by id. Statements split at line-ending semicolons, with or without a -- comment after the semicolon. A semicolon inside a string literal, a quoted identifier, a /* */ comment, or a Postgres dollar-quoted body ($$ ... $$ or $tag$ ... $tag$) does not split, so a PL/pgSQL function stays one statement. A MySQL trigger or procedure has bare semicolons in its body, so put a DELIMITER // line before it and a DELIMITER ; line after it, as the mysql client reads them. Between the two lines, // ends each statement wherever it sits on its line, and the directive lines are not sent. A DELIMITER line whose token contains a quote, such as a Postgres COPY option DELIMITER ',', is SQL and stays in the statement.
A -- sustained: no transaction line of its own in an up file or a repeat file sets transactional=False on that migration. Case does not matter, and the two words may be joined by a space, a hyphen, or an underscore. declares_no_transaction(text) reports the same thing for one file’s text. Sustained reads the marker from the up file and the repeat file only, because the flag already covers the down step.
load_migrations raises ValueError for a missing directory, for a file that matches none of the naming patterns, for an id with both an up file and a repeat file, for a down file with no up file, and for an empty up, down, or repeat file. The naming check reads every file in the directory, whatever its extension, so a misnamed migration such as 0002_add.up.sq raises instead of loading nothing. It skips subdirectories, dotfiles, and editor backup files (*~, *.bak, *.orig, *.swp, *.swo, *.tmp); every other file must follow a naming pattern, so keep a README outside the migrations directory.
substitute_placeholders(text, placeholders, source) -> str
substitute_placeholders fills the ${key} markers. Write $${ for a literal ${. The function returns the text unchanged when placeholders is None. It raises ValueError, naming the file, for an unknown key or a malformed marker.
Any mapping turns substitution on, even an empty one. Substitution happens before Sustained computes the checksum, so the checksum covers the SQL that ran.
split_sql_statements(text) -> list[str]
split_sql_statements splits on line-ending semicolons, including a semicolon with a -- comment after it, and drops the pieces that are only whitespace or comments. It skips semicolons inside quotes, /* */ comments, and dollar-quoted bodies. A doubled quote stands for one quote. The text does not say whether a backslash escapes a quote, as in MySQL’s 'it\'s', or ends a standard string such as 'C:\', so the function reads the text both ways and splits at a semicolon that either reading puts outside every string. A reading that leaves a quote or a comment open at the end of the text is dropped, and when both readings leave one open, the function splits at every line-ending semicolon, so a misread quote cannot merge the whole file into one statement. A line-ending semicolon inside a -- comment still splits.
Autogeneration internals
These names live in sustained.autogenerate. plan() and up(models=[...]) use them.
diff_schema(connection, models, dialect=Dialects.DEFAULT, exclude_tables=('sustained_migrations',), renames=None, table_renames=None, snapshot=None) -> SchemaDiff
Changes nothing and reports every difference, drops included. Pass snapshot to compare against a schema you already read with introspect_schema(), and the diff then does not touch the connection. The diff applies the rename hints to that snapshot in place, so the snapshot shows you the same renamed schema the diff compares against.
autogenerate(connection, models, id, dialect=..., allow_drops=False, ignore_changed_columns=False, exclude_tables=..., renames=None, table_renames=None, type_casts=None, ignore_undeclared=False, snapshot=None, assert_algorithm=False) -> Migration | None
Builds the migration a diff asks for. Pass snapshot to build it from a schema you already read with introspect_schema(). The rename hints apply to a copy, so the same snapshot can feed several calls. Refuses to generate the lossy differences, and refuses to run at all while the database contains objects the models do not declare, unless you pass allow_drops=True or ignore_undeclared=True. The migrator passes ignore_undeclared=True. A CHECK constraint that no model declares never causes a refusal. It comes back as a note on the diff instead, because engines rewrite check expressions and the comparison cannot justify a refusal.
assert_algorithm=True reads the server facts from the connection with sustained.impact.read_context() and writes the clauses the assert_algorithm option of plan() writes.
autogenerate_migrations(connection, models, id, dialect=..., allow_drops=False, ignore_changed_columns=False, exclude_tables=..., renames=None, table_renames=None, type_casts=None, ignore_undeclared=False, snapshot=None, assert_algorithm=False, online=False) -> list[Migration]
Builds the migrations a diff asks for, as a list that is empty when the schema is up to date. The arguments are those of autogenerate(), and without online the list is the one migration autogenerate() returns.
On PostgreSQL, online=True splits the work into two migrations. The migration named id runs in one transaction and changes only the catalog; a new NOT NULL column whose backfill is a value goes in with that value as a default it drops again. The migration named <id>_online has transactional=False, so each of its statements commits on its own, and it has the statements that read or write rows. Each of them can run again over what a failed run left, and on a partitioned table an index is built on each partition and attached. A migration with no statement is left out of the list. On MySQL and MariaDB, online=True does what assert_algorithm=True does. Other dialects ignore it. Online migrations lists the statements of each migration.
introspect_schema(connection, dialect=Dialects.DEFAULT, schemas=()) -> dict[str, IntrospectedTable]
Reads the live schema. await async_introspect_schema(adapter, dialect=Dialects.DEFAULT, schemas=()) is the same read on an adapter. The read covers the schema the connection is on, plus every name in schemas; diff_schema() fills that from the models’ tableSchema. See Schema scope.
The Postgres read also sets IntrospectedIndex.valid to False for an index pg_index reports invalid, and IntrospectedTable.not_valid to the lowercased names of the foreign keys and checks pg_constraint reports not validated. A partitioned table has partitioned=True and lists its partitions in partitions, as IntrospectedPartition(name, schema, partitioned, partitions), and a partition names its table in partition_of. Other reads leave the defaults: every index valid, no constraint not validated, and no partitions. See Invalid indexes and constraints not validated.
diff_snapshots(before, after, dialect=None) -> list[str]
One line per difference between two introspected schemas. Tables and columns are compared. When dialect is Dialects.POSTGRES, Dialects.MYSQL, Dialects.MSSQL, or Dialects.DEFAULT, indexes are compared too: the key columns, uniqueness, DESC key parts, prefix lengths, and predicate of each index that no constraint owns. When dialect is Dialects.POSTGRES, Dialects.MYSQL, or Dialects.MSSQL, checks, UNIQUE constraints, and foreign keys are compared too. See Rehearsing a migration.
normalize_type(raw) -> str
The canonical spelling of a reported column type, for comparison.
normalize_default(raw) -> str | None
The canonical spelling of a reported column default, for comparison. Balanced outer parentheses, a Postgres ::type cast with its length, quotes, and an empty argument list all come off. A nextval(...) default returns None, because Postgres reports it for a serial column and no model declaration can equal it, so the diff reports nothing for it.
SchemaDiff
| Attribute | Contains |
|---|---|
missing_tables |
Models with no table |
new_columns |
(model, name, ColumnDef) |
extra_tables |
Table names the models do not declare |
extra_columns |
(table, column) |
changed_columns |
(table, column, actual, expected) |
new_indexes, extra_indexes, changed_indexes |
Index differences |
new_enum_types |
(name, values) for enum types the models declare and the database lacks |
changed_enum_types |
(name, live_values, declared_values) for enum types whose values differ |
new_foreign_keys, changed_foreign_keys, extra_foreign_keys |
Foreign key differences, by constraint name |
new_checks, changed_checks, extra_checks |
CHECK constraint differences, by constraint name |
constraint_notes |
Differences that are reported but never auto-migrated |
is_empty() returns whether the diff found any difference. summary() returns one readable line per difference, with the destructive ones marked, or schema up to date when there is no difference.
The enum buckets fill on the dialects with named types. Postgres compares against pg_enum, and DuckDB against duckdb_types(). A DuckDB too old for that view falls back to reading the values from the column’s inline type spelling, and a type with no column left reads as absent there. Missing foreign keys and checks generate ADD CONSTRAINT, and allow_drops gates the changed and extra ones. Primary key set changes, column-level UNIQUE, and default differences always land in constraint_notes, and a Postgres check expression whose difference remains after normalization goes there too. Generation never migrates a note for you.
What generation refuses
autogenerate() raises ValueError instead of guessing, for each of these:
- A drop without
allow_drops=True. The message names the objects and the flag. - Tightening a column to NOT NULL with no
backfillordefault. - Adding a NOT NULL column with no
backfillordefault. - Adding a primary key or autoincrement column, which ALTER TABLE cannot do.
A migration that includes a drop has no down step, and neither does one that includes a SQLite table rebuild, because neither one reverses.
Analysis
These names live in sustained.analysis, and sustained plan uses them.
destructive_statements(statements, dialect=None) -> list[str]
The statements that remove data or an object that contains it: DROP TABLE, DROP COLUMN, DROP TYPE, DROP VIEW, DROP MATERIALIZED VIEW, DROP DATABASE, DROP SCHEMA ... CASCADE, a constraint drop, TRUNCATE, and DELETE FROM. The returned statements have comments removed and whitespace collapsed. dialect names the engine whose comment and whitespace rules the scan reads; with none, the scan takes every engine’s reading. Skips index and key drops, and a plain DROP SCHEMA, which refuses a non-empty schema.
summarize(migration, state, compiler=None) -> PendingSummary
One migration reduced to its id, state, repeatable flag, statement count, and destructive statements. Ddl steps render for the given compiler’s dialect, or ANSI when none is given.
PendingSummary(id, state, repeatable, sql, destructive) contains that summary. sql is None for a callable step, which has no SQL to count. Each statement in it is a MigrationStatement.
MigrationStatement(statement, migration_id=None, transactional=None, destructive=None, intent=None, impact=None)
One statement with the migration it came from. It subclasses str, so anything that reads statements as strings reads these too. migration_id is the migration’s id, or None when nothing named one. transactional is that migration’s transaction flag, True when nothing gives one. destructive marks a statement the diff knows removes data. intent is what a generated statement is meant to do. impact is the statement’s StatementImpact, which up() sets before the guards run. A statement wrapped again keeps the migration_id, transactional, destructive, intent, and impact of the statement it wraps when none is given. None of destructive, intent, and impact takes part in equality or in a checksum.
statement_scope(statement) -> tuple[str | None, bool]
The migration id and transaction flag of one statement. A plain str gives (None, True).
The scan is textual. It labels a column drop written without the COLUMN keyword, which MySQL allows. It keeps comments and quoted text out of the scan, so a drop named inside a string literal is not labelled. It reads comments as the given dialect’s server does, including the SQL inside a MySQL /*! ... */ comment, and with no dialect a drop that any engine’s reading finds is labelled. The label is a report for the operator, and migrate reads the same list for its rehearsal gate.