Every released version of Sustained, newest first. The same text lives in CHANGELOG.md in the repository; this page is generated from it.
Version numbers follow semantic versioning. A major version marks a change that can break working code. A minor version adds new features. A patch version fixes a defect without changing public API signatures or introducing new functionality.
2.24.1
Fixed
- A sqlite3 connection opened with
connect(factory=...)was not recognized, so a non-transactional migration kept the implicit transaction and SQLite ignored the foreign key pragmas a rebuild needs. Detection now tests the class, not the module name. - A schema read on Presto or Trino no longer raises when two schemas contain a table with the same name. Those catalogs read every schema they can see, and the API offers no way to narrow the read, so the refusal left those callers stuck. They keep the constraint join fallback instead. Dialects whose read is scoped to a schema, such as Postgres, still refuse the duplicate name.
- A statement handed the pool inside
async_transaction(pool)runs on the adapter the block checked out. It checked a second adapter out, so the write ran outside the transaction and committed on its own, and a pool of one adapter deadlocked intoPoolTimeout. A nestedasync_transaction(pool)had the same defect and now opens a savepoint on that adapter, the way the blockingtransaction(pool)nests. ConnectionPool.release()attempts the rollback on every release. It stopped asking after one driver refusal, which duckdb produces whenever no transaction is open, so a connection released later with a transaction open went back to the idle queue still inside it and the next caller inherited its locks and its snapshot.AsyncConnectionPool.release()probes an adapter whose rollback raised withSELECT 1and keeps the one that answers. It dropped the adapter for any rollback error, and duckdb raises whenever no transaction is open, so every release closed and reopened the connection and an in-memory duckdb database lost its contents after the first one.- A rehearsal leaves a migration with
transactional=Falseout of the run. It ran the migration inside the rehearsal transaction, whereCREATE INDEX CONCURRENTLYraises and SQLite ignores the rebuild pragmas, so the rehearsal failed a migration a realup()applies and a destructive run that contains one could never earn its rehearsal row. The result reportsup_okasNonewith the reason, and the run can still pass. - The model registry keeps the newest class when a module reload rebuilds one. The identity comparison read the rebuilt class as a second definition, so a dev-server autoreload or a re-run notebook cell marked the name ambiguous forever and every string reference to it raised, naming the same class twice.
AsyncMigrator’s schema read runs through the shared guarded loop, so on Postgres each catalog query takes a savepoint and one missing view no longer poisons the transaction for every statement after it.async_introspect_schema()gains arecorderargument that receives each plan statement and its rows, which is how the migrator keeps its recording; the savepoints stay out of it.- A type or nullability change on MySQL and SQL Server restates the column’s current default and comment, read from the catalog, instead of the model’s. The restated definition folded a default or comment drift in silently, and the down step wrote the model’s default over the one the column had, so the round trip lost it. Such a drift stays a note on the diff.
- The rehearsal prefix keys compute each migration’s checksum once. The slice loops recomputed it per start and end pair, which on a run of fifty pending migrations hashed the statements over a thousand times.
2.24.0
Added
- A migration can run outside a transaction:
Migration(transactional=False), or a-- sustained: no transactioncomment in a SQL file.CREATE INDEX CONCURRENTLYneeds this. A failure leaves the earlier statements applied; clean up and runrepair(). AsyncMigratorruns the whole workflow. It gainsscript(),plan(), anddrift(), andup()andrehearse()takemodelsand the diff options. The async path records its schema read withSchemaReadand replays the recording into the diff.AsyncConnectionPoolopens adapters from an async factory up tomax_sizeand works withModel.bind_async(). It refusesfetch(),execute(), andcommit()on itself; usepool.scope(), which pins one connection for a statement and its commit.- Guards see migration boundaries: each statement arrives as
MigrationStatement, astrsubclass carrying the migration id and its transaction flag.no_lock_without_timeoutnow scopes aSET LOCAL lock_timeoutto its own migration’s transaction. - Check constraints are read on MySQL, MariaDB, SQL Server, and DuckDB, so a declared
Checkdiffs there. Presto and Athena stay unread through the newreads_checksflag. Engine-written NOT NULL andjson_validchecks are filtered out. - DuckDB enum types are read from
duckdb_types(), so a type no column uses stops reading as absent and generation no longer emits a duplicateCREATE TYPE. A DuckDB too old for the view falls back to the old inference. crossJoin()accepts the table on its own, since a cross join has no condition.
Fixed
- A
Subqueryin a select list, function argument, or join condition renders with placeholders through the statement’s parameters. It used to inline its values as literals, which defeats statement caching.str()on a builder still inlines. - A nested
CASE,Func, aggregate, window, orcol()used as a function argument or comparison value renders through the active compiler, via the newCompiler.format_operand(). It used to render for the wrong dialect or bind the object as a parameter. CaseExpression.__str__escapes a string result throughcompile_case, so a result such asO'Brienno longer breaks the SQL.- Identifiers double the quote character inside a name on every dialect, so a name carrying the delimiter cannot end the quoted span early.
on(),andOn(), andorOn()validate the join operator against the setwhere()accepts, so outside input cannot append SQL to the ON clause.- Raw SQL fragments count
?markers with a quote-aware scan; a question mark inside a string literal no longer miscounts or shifts values. - A multi-row insert whose values include a raw
Expressionruns as one statement rather than throughexecutemany(). Batch inserts also report their row values to the statement listener. - An offset with no limit renders
LIMIT -1 OFFSET non the default dialect, which SQLite accepts. Postgres and DuckDB keep their bareOFFSETthrough the newcompile_offset_without_limithook. - Eager loading gives each parent its own list of children; two parents that share a join key no longer share one list object.
- A query handed an explicit pool inside
transaction(pool)runs on that block’s pinned connection instead of checking out a second one and committing on its own. - Every cursor closes when its statement finishes, through
execution.cursor_scope(), which stops pyodbc and MySQL “commands out of sync” errors.close()joins theCursorprotocol, so a test double needs it. Model.create_table()andModel.drop_table()commit their DDL when notransaction()block owns the connection.ConnectionPool.release()rolls back before re-queueing, probes withSELECT 1and drops a connection that fails, and raisesValueErrorfor a double release or a foreign connection.transaction()belongs to the thread that opened it; a second thread getsRuntimeError. A nested rollback releases its savepoint, and a failing rollback no longer replaces the caller’s error.async_transaction()picks driver or statement transaction control the waytransaction()does, through the newdriver_transaction_control(), so psycopg2 no longer refuses the explicitBEGIN. Autocommit and legacy sqlite3 keep the statements.- A rehearsal runs inside one pinned transaction via
execution.pinned_transaction. On DuckDB each cursor is its own session, so rehearsed DDL used to commit as it ran and the closing rollback took nothing back. - A rehearsal records a row for every start point as well as every end point, so
up(target=A)thenup(target=B)no longer demands a rehearsal it already proved. Building the keys costs one pass instead of re-hashing every prefix. - The advisory lock result is checked before the run starts. MySQL
GET_LOCKand MSSQLsp_getapplocksignal refusal in their return value; both migrators now raiseMigrationErrorthrough the newmigration_lock_problem(). script(),status(),statuses(),pending(),validate(), andplanno longer create or upgrade the tracking table. They read through the newread_applied_records(), and a database without the table reports every migration pending.down()checks the whole revert window before reverting anything, so a run no longer stops half reverted on an edit or a missing down step it knew about at the start.AsyncMigrator.down()reverts a migration generated from the models; it read the fetch result wrong and raised “not registered with this migrator”.- A generated migration that failed no longer joins the registered list, so a long-lived migrator does not repeat its SQL on the next
up(). - The migration’s own error survives a failed switch back to transaction control. A refused switch leaves the connection in autocommit; open a new connection to get transaction control back.
- The statement splitter takes a semicolon followed by a comment, so
...); -- noteno longer glues the next statement onto that one. - The migration file naming check reads every file in the directory, so a typo’d extension such as
.sqraises instead of loading nothing. Subdirectories, dotfiles, and editor copies are passed over. - The destructive scan labels
DELETE FROM,DROP VIEW,DROP MATERIALIZED VIEW,DROP DATABASE, andDROP SCHEMA ... CASCADE. A token pass keeps a--inside a string literal from hiding a drop, and a drop named inside quotes is no longer labelled. - The
plancommand keys its guard verdicts by the normalized statement, so a custom guard’s verdicts reachplan --jsoninstead of an empty list. - A result set that repeats a column name raises the new
AmbiguousColumnserror naming the columns, instead of the last value silently winning in every row dict. The check runs everywhere a row dict is built, sync and async. - Attribute access on a model instance raises for a column the row does not carry, instead of answering the column name string, so
hasattr()tells whether a field was loaded. Class access is untouched. - The model registry no longer resolves a shared name to the wrong class. A string reference resolves through the module that declares the relation and raises
ValueErrornaming every candidate when that fails. - Views stay out of the shared
information_schemaread on MySQL, MariaDB, SQL Server, DuckDB, Presto, and Athena, so a view no longer diffs as an undeclared table or draws aDROP TABLE. - MySQL
MODIFYand SQL ServerALTER COLUMNrestate the whole column through a newColumnState, so an alter no longer drops NOT NULL, the default, the identity property, or the comment. Down steps carry the same fix. - Postgres foreign keys are read from
pg_constraint, so two same-named keys on different tables in one schema no longer cross-multiply into a spurious drop and re-add. - Columns compare by their lowercased names, and the diff and the step generator share one type-change predicate, so a nullability-only drift no longer regenerates the type and truncates a MariaDB
datetime(6). normalize_check()also strips identifier quoting and operator spacing, so a check the engine rewrote compares equal to its declaration. A call keeps its parentheses, so two different checks stay different.normalize_default()reducesnextval(...)toNone, strips outer parentheses only when they pair, and covers a cast with a length such as::character varying(255).- New tables are created in dependency order. Where the engine takes
ALTER TABLE ADD CONSTRAINT, every foreign key followsCREATE TABLEas its own statement, so tables may point at each other. The ordering walk survives thousand-table chains. - The table rebuild is kept to dialects that can run it. The new
rebuild_strategy()makes Presto and Trino raiseDialectErrorwith a hand-written recipe instead of failing on the first statement. - A SQLite rebuild of a referenced table runs between
PRAGMA foreign_keys = OFFandONwithtransactional=False, since SQLite ignores the pragmas inside a transaction. A failed step leaves enforcement off on that connection; the docs say how to restore it. - The refusal of a new NOT NULL column with no default and no backfill runs before the rebuild path, refuses only while the table contains rows, and counts an unreadable row probe as rows.
- SQLite pragmas quote the table and index name, so a name with a space or a double quote reads.
- Each Postgres catalog query runs inside a savepoint, released after a rollback, so one missing view no longer aborts the whole read. The async read stops asking once the connection refuses savepoints.
- Athena records a comment change as a diff note instead of stopping the run, since Athena refuses the change in place. A hand-written
set_column_commentstep still raises. - Athena
TBLPROPERTIESkeys and values escape their quotes. - SQL Server
sp_renameparses the bracketed path into segments and passes only the final segment as the new name, so quoted and schema-qualified names work. - The live schema is read once per
autogenerate()run instead of twice, and column comments are selected beside the other column data instead of in a second full read. - The CLI removes the
sys.pathentry it added by value, not by position, so a config module that prepends its own directory keeps that entry. - A compiler override written before the render context keeps working. The
Compilerbase class wraps the old signature at class creation, includingstaticmethodandclassmethodoverrides. GuardBlocked([])builds its message instead of raisingValueErrorover an emptymax().- A capitalized join spelling such as
LeftJoinresolves, and an unknown join-shaped name raisesAttributeErrorsohasattr()works. - The docs say a row count of
-1from an async write means the driver reported no count; asyncpg does this for batched inserts.returning()gives an exact count. - The matrix runner runs the container-free targets when compose fails, instead of reporting sqlite, duckdb, and athena as not started.
- The README links to the schema guide with the site URL, so the link works on GitHub and PyPI.
Changed
- Every parameter after
allow_out_of_orderonMigrator.up()andAsyncMigrator.up()is keyword-only, so a positional call written for an earlier release raisesTypeErrorat once.rehearse()keeps its signature. MigrationraisesValueErrorwhen a checksum is given on SQL, statement-list, or ddl steps, which hash themselves; a pinned checksum hid edits from validation. Callable steps still take one. Runrepair()if a stored row no longer matches.down()refuses a migration whose checksum no longer matches its tracking row, asup()already did.allow_changed, andsustained down --allow-changed, revert with the down step as it stands.down()refuses a revert count below 1. A negative--stepsused to revert everything but the oldest and exit 0; a count of 0 still reverts nothing.- A run that includes
DELETE FROM,DROP VIEW,DROP MATERIALIZED VIEW,DROP DATABASE, orDROP SCHEMA ... CASCADEneeds a passing rehearsal row beforemigrateapplies it, or--unrehearsed. A plainDROP SCHEMAstill passes. no_lock_without_timeout()reads the run in order, so aSET lock_timeoutwritten after anALTER TABLEno longer excuses it.- An undeclared check read on MySQL, MariaDB, SQL Server, or DuckDB becomes a diff note rather than a refusal, since engine rewrites make the comparison unreliable.
allow_dropsstill drops it. - Postgres, SQL Server, and DuckDB reads cover the connection’s schema plus every schema the models declare, instead of every non-system schema, since the snapshot keys on bare table names. Presto and Trino stay unscoped. MySQL widens from
DATABASE().
2.23.1
Fixed
- Athena parameterized queries execute: the placeholder is now
?instead of%s, which pyathena’s pyformat style could not take as a tuple. Setpyathena.paramstyle = "qmark"(pyathena 3 or later) so the tuple travels as native execution parameters. - Athena DDL quotes identifiers with backticks for the Hive parser, through the new
quote_ddl_identifiercompiler hook. Queries andMERGEkeep double quotes for the Trino engine. - Every Athena string column renders
STRING, which Iceberg tables need. The newnormalize_diff_typehook folds the reportedvarcharback, so the column never drifts against its own DDL. - Athena execution parameters travel as strings through the new
prepare_executionhook: numbers viastr(), booleans astrue/false,Noneas a literalNULL; binary raisesDialectError. Passto_sql()output through it if you execute it yourself. - Athena introspection reads only the connection’s schema. It read every Glue database in the account, which was slow and failed on any table with broken metadata.
2.23.0
Added
- Column comments: every column definition takes a
comment, stored where the engine has a place for one. Introspection reads them back, diffs report a change, andset_column_commentcovers hand-written migrations. Athena refuses a change afterCREATE TABLE.
2.22.0
Added
Binary()insustained.schemadeclares a bytes column:BLOBby default,BYTEAon Postgres,VARBINARY(MAX)on SQL Server,BINARYon Athena. Introspection folds the variants back, so no drift. MySQL treats it off-row: no unique key, no literal default.- The Covered column on the support page is proven: each cover maps to a module in
tests/integration, and a contract test fails whensupport.jsonand the test classes disagree. Five covers: queries, writes, transactions, migrations, async. matrix.pygains a<name>-latesttarget per container database, running the newest vendor-supported release pinned insupport.json.
Fixed
transaction()spells nested-transaction savepoints per dialect:SAVE TRANSACTIONon SQL Server, and nesting on DuckDB raisesDialectErrorbefore any statement is sent.transaction()works on the duckdb driver, which autocommits and gives each cursor its own session. A transaction now pins one cursor for every statement inside it, so a failed multi-statement migration rolls back.async_transaction()renders its transaction control through the dialect compiler the same way.- sqlite3 connections in legacy transaction control get an explicit
BEGINfromtransaction(), so a rolled-back block no longer keeps its schema changes. - A NOT NULL change with a
backfillon DuckDB compiles toSET DATA TYPE ... USING coalesce(...), because DuckDB refusesSET NOT NULLafter anUPDATEin the same transaction. - Plain indexes are read back on MySQL, MariaDB, SQL Server, and DuckDB, so a declared index no longer drifts on every plan. Indexes that back foreign keys never demand
allow_drops. - Set-operation members render without parentheses on the default dialect, which SQLite rejects, so
union()and its siblings run there. A bare member with its own ORDER BY or LIMIT raisesDialectError.
2.21.0
Added
Enum(*values, name=...)insustained.schemadeclares a column over a named, ordered value list. Postgres and DuckDB create a named type, MySQL renders inlineENUM(...), the default dialect and MSSQL render VARCHAR plus a CHECK. Presto and Athena refuse it.- Migration generation covers enum types:
ALTER TYPE ... ADD VALUEon Postgres (irreversible; PostgreSQL 12 is the floor for rehearsing it), a restatedMODIFY COLUMNon MySQL, a re-created CHECK elsewhere. Removing or reordering values refuses with a rebuild recipe. Check(name, expression)andForeignKey(name, columns, references, on_delete=, on_update=)declare named table constraints in the newtableConstraintsattribute, with composite columns and the five referential actions.- Migration generation covers those constraints: a missing one generates
ADD CONSTRAINTwith a drop as its down step; changed and undeclared ones are gated byallow_drops. SQLite routes constraint changes through its table rebuild. sustained.ddlprovides typed steps for hand-written migrations, fromcreate_tableto a rawsql()escape hatch. A step renders through the dialect compiler at run time, so one migration serves every dialect, and its checksum hashes the operation rather than the rendered SQL.- A
Migrationwhose up step is all reversible ddl steps derives its down step, newest first. Any drop,add_enum_value, orsql()refuses and asks for an explicit down step ordown=None. - Postgres introspection gets a dedicated read: real foreign key targets, non-unique indexes, varchar lengths, precision and scale, CHECK expressions, and enum value lists. SQLite recovers constraint names from
sqlite_master; MySQL parses inline enums.
Changed
DROP TYPEandDROP CONSTRAINT(withDROP CHECKandDROP FOREIGN KEY) count as destructive:planlabels them,no_drops()blocks them, and the rehearsal gate covers them. Index and key drops still pass.supports_constraints()isFalseon Presto, which enforces none. Declared table constraints raiseDialectErrorthere and on Athena, and the tracking table renders without constraints on both.
2.20.0
Changed
- The row a rehearsal writes is called a rehearsal row throughout the documentation and the code, in place of the earlier word “receipt”.
rehearsal_key()replacesreceipt_key()insustained.migrations, and the outcome constants areREHEARSAL_PASSED,REHEARSAL_FAILED, andREHEARSAL_OVERRIDE. The stored table, its column names, and every key a database already holds are untouched, so a rehearsal recorded by an earlier version still opens the gate. sustained rehearseprintsrehearsal row recorded, and a scratch run that covered too little printsrehearsal row not recorded, where both lines saidreceiptbefore. A script matching that text needs updating.
Deprecated
receipt_key(),RECEIPT_PASSED,RECEIPT_FAILED, andRECEIPT_OVERRIDEinsustained.migrations. Each still imports and raises aDeprecationWarningnaming its replacement, and goes away in 3.0.
2.19.0
Added
- A written support policy.
runsmeans the integration suite applies migrations to a real server;buildsmeans the SQL compiles under unit tests. The page also states the Python floor, the deprecation path, and what each version number promises. - That list lives once, in
support.json.sync_support.pyrenders the page from it, and a pre-commit hook fails when they disagree or a claim has no test module or compose service behind it. - An integration suite in
tests/integration/: one shared body applies the models, diffs, migrates, reverts, rehearses, validates, repairs, contends for the advisory lock, and round-trips a query on every server.SUSTAINED_TEST_STRICT=1turns skips into failures. matrix.pyruns that suite: it starts servers fromdocker/compose.yaml, runs each module, and prints one line per server. Exit 0 clean, 1 failure, 2 waiting. A set connection variable uses that server and starts no container. Athena runs in your own AWS account.Compiler.compile_create_table()renders the whole CREATE TABLE statement, so a dialect that spells the if-missing check differently overrides one method.
Fixed
- Tracking table columns quote through the dialect compiler.
generatedis reserved in MySQL, so the column probe read it as missing and every run tried to add it again. - The MySQL advisory lock waits with a one-year timeout rather than a negative one, which MariaDB answers with NULL, so the lock was silently absent.
- SQL Server creates the tracking tables behind
IF OBJECT_ID(...) IS NULL, since T-SQL has noCREATE TABLE IF NOT EXISTS.
2.18.0
Added
Dialects.MYSQLcompiles for MySQL and MariaDB: backtick quoting,%splaceholders,ON DUPLICATE KEY UPDATEupserts,AUTO_INCREMENT,MODIFY COLUMN, aGET_LOCKadvisory lock, andfor_update()withSKIP LOCKEDandNOWAITon MySQL 8.0.- Column types render in the spelling
information_schemareports back, so no drift:INT,TINYINT(1)forBoolean,DOUBLE,DECIMAL, andDATETIMEforTimestamp, whoseTIMESTAMPalternative stops in 2038 and converts time zones. - MySQL introspection reads
column_typerather thandata_type, scopes every query toDATABASE(), and matches schemas as well as names in the constraint join. - MariaDB stores a
Json()column aslongtextwith ajson_validCHECK; the read restores the JSON type so the column does not drift. MariaDB before 10.2.22 has nocheck_constraintsview and does drift. Compiler.supports_transactional_ddl()reports whether rolled-back DDL really goes away. MySQL is the first engine where it differs fromsupports_transactions(): rows roll back, DDL commits as it runs.Compiler.inline_references()reports whether an inlineREFERENCESclause creates a foreign key, withcompile_add_foreign_key()andcompile_drop_foreign_key()for the dialects that say no.
Changed
- Schema reading moved from
sustained.autogeneratetosustained.introspect. Every name re-exports from the old module, so imports keep working, andtype_paramsis now public. - Default normalization drops an empty argument list, so
current_timestamp()andCURRENT_TIMESTAMPcompare equal. Type normalization gained the*TEXTvariants;TINYINTstays out, sinceTINYINT(1)is MySQL’s boolean.
Refused
rehearse()refuses MySQL against the real database, since its rollback takes nothing back. Passscratch=True, or defineget_rehearsal_connection()for the CLI. Recovery after a half-failed run isrepair().returning()raises on MySQL, including against MariaDB, which supports it; SQL only one of the two servers accepts is worse than neither. Use a second query orLAST_INSERT_ID().STRING_AGGraises rather than translating toGROUP_CONCAT, whose separator is a keyword and not a second argument.- A
Text()orJson()column takes neither a unique key, which MySQL wants a prefix length for, nor a literalDEFAULT, which it refuses. - An unsigned integer column has no
tableColumnsdeclaration, so one already in the database reports as drift no migration closes.
2.17.0
Added
- The tracking table gains a
stepscolumn storing a generated migration’s up and down statements as JSON, sodown()can revert what a process never diffed. Older tables add the column on first use. up(unrehearsed=True)records what it waived: a rehearsal row with the outcomeoverride, which never opens the gate for a later run.migrateexits 4 when a run that removes data has no passing rehearsal, which a pipeline can tell apart from a failure.Migrator.drift()andSchemaDiff.outstanding()takeignore_changed_columns.- A block or missing rehearsal row on a generated migration names the registered migrations that already applied, on the exception’s
appliedattribute.
Fixed
- A SQLite table rebuild no longer drops undeclared columns, their data, and hand-made indexes; they cross the rebuild with type, nullability, uniqueness, and default.
allow_drops=Truestill drops them, and an expression index still cannot cross. - An index on an expression no longer crashes introspection; SQLite reports a null column name for one, so it is left out of the schema.
up(models=[...])runs the out-of-order validation check again for hand-written migrations.- A rehearsal with models and rename hints no longer raises while checking that the models landed; the check runs without the hints and honours
ignore_changed_columns. - A rehearsal no longer blames its leftovers on the down steps that did reverse; the comparison runs only when every versioned migration in the run reversed.
- A rehearsal applies the generated migration before the repeatables, matching
migrate. - A scratch rehearsal records rows for the shorter target sets, so
migrate --targetis not refused for statements the scratch run proved. - The plan footer no longer says
run: sustained rehearseafter a rehearsal recorded its row, and no longer computes verdicts over dropsmigratenever generates. no_lock_without_timeout()is Postgres only and anchored to aSETstatement, so an update of a column namedlock_timeoutno longer fires it.- Filter and write values accept
datetime,date,Decimal, andbytesunder a strict type checker. - A sync savepoint that failed to open no longer leaves the nesting depth one too high, which reused a savepoint name.
2.16.1
Added
ConnectionandCursorinsustained.types, protocols listing the DB-API 2.0 methods Sustained calls, so any conforming driver connection matches.Binding, theUnion[Connection, ConnectionPool]thatModel.bind()and everyconnection=argument take.SqlValueandRowValuesplit database values by direction: a value going in isobject, a value read back staysAny.ColumnDescriptionandRelationTreeinsustained.types, andJsonValueinsustained.cli.- Driver protocols in
sustained.aio:AsyncpgConnection,AsyncpgRecord,AiosqliteConnection, andAiosqliteCursor.
Changed
- Roughly 180
Anyannotations were replaced with the types above and with model, schema, and introspection types. TheAnyannotations that remain each carry a comment giving the reason. - Migration callbacks and callable steps are typed
Callable[[CallbackTarget], CallbackResult]; a callable step on the async path may return an awaitable. in_()andnot_in()accept any sequence of values, not only alist.ConnectionPoolcloses connections by callingclose()directly; the DB-API requires the method.- The builder stubs declare
render(),has_clauses(), thecompilerargument, and the method mapsQueryBuilderreaches for.
2.16.0
Added
- The query builder is generic over its model:
Show.query()is aQueryBuilder[Show], sorun()isList[Show],first()isOptional[Show], andarun()andafirst()match. No cast needed. WriteBuilder[Model], returned byinsert(),insert_from(),create_table_as(),update(), anddelete(); itsrun()types as the row count or the RETURNING rows. At run time it is the same class asQueryBuilder.WriteResultinsustained.types, the union a write returns.QueryBuilder[Show]works in a run-time annotation.
Changed
- Argument positions that take any query are declared
QueryBuilder[Any], since the builder is invariant in its model. - The generic types live in
builder.pyionly; the running code is unchanged.
Not covered
- The select list does not narrow the result:
select('id')still types as the whole model, andto_dicts()values stayAny.
2.15.0
Added
- Guards: rules over the statements a run would apply, returning a
Verdict(rule, verdict, statement)ofblockorwarnper objection. Both migrators takeguards=[...], and the CLI config module names them. sustained.guardsships five factories:no_drops(),index_must_be_concurrent()(Postgres only),no_table_rewrite()(warns),no_lock_without_timeout(), andmax_statements(n), plusrun_guards(),blocking(), andwarnings_only().up()raisesGuardBlockedon a blocking verdict before any statement runs and prints warnings on stderr. No flag waives a guard: fix the statement or take the rule out.sustained planruns the guards and prints aguardssection, one line per verdict; in--jsona verdict rides on its statement object.- Exit code 3 means a guard blocked a statement, from
planandmigrate. Precedence is 1 (problems) over 3 (blocked) over 2 (pending). - Both migrators take
callbacks=Callbacks(...), sobefore_migrate,after_migrate, andon_errorreach library callers, not only the CLI. - A
dialectproperty on both migrators, andrun_statements()andcheck_guards()insustained.migrations.
Changed
sustained migratehands its config module callbacks to the migrator;after_migratenow fires before the post-run drift report.plan --jsonstatement objects gained aguardskey, present and empty when unflagged.- Guards run twice on a run that includes the model diff, since the generated statements arrive late; a warning already printed is not repeated.
rehearsedoes not enforce guards, since it rolls back and blocking would stop an operator testing the statement they are fixing.
2.14.0
Added
- A passing rehearsal writes one row in the new
sustained_rehearsalstable, keyed by a SHA-256 over the applied and run checksums. A failing rehearsal records the failure under the same key. up()refuses a statement that removes data — a DROP TABLE, a column drop, or a TRUNCATE — unless a passing rehearsal row covers that exact set.up(unrehearsed=True)andsustained migrate --unrehearsedapply anyway. An additive run is never gated.- A rehearsal records a row for each shorter
--targetrun that removes data, so one rehearsal covers the whole run and every target within it. RehearsalRequired, insustained.exceptionsand re-exported at the root, is what the refusal raises.record_rehearsal(),rehearsal_outcome(), andrehearsed()on both migrators, andrehearsal_key(applied, run)insustained.migrations.rehearse --jsongainskeyandrecorded, and the plain report printsrehearsal row recorded.- Both constructors and the CLI config module take
rehearsal_table.
Changed
rehearse()returns aRehearsal, alistsubclass carryingkey,recorded, andok.rehearse(scratch=True)records nothing through the API. The CLI writes the row on the real database after a passing scratch run that applied everything pending there.sustained planprintsrun: sustained rehearsewhen a pending migration removes data, since migrate would refuse it.- Both Sustained tables are excluded from every diff against the models.
rehearsal_failed(result)moved fromsustained.clitosustained.migrations.
2.13.0
Added
Migrator.up(models=[...])diffs the models against the database, applies the generated migration after everything else pending, and takes the diff optionsplan()takes. It replacessync(). A target cannot combine with models.sustained migrateandsustained rehearsepass the config module’smodels, so the model diff reaches the shell. A targetedmigrateapplies registered migrations only.Migrator.rehearse(models=[...])rehearses the generated migration alongside the pending ones without registering it.- A rehearsal reports what the schema said:
landedsays the models arrived, andreversedcompares against a pre-run snapshot.Nonemeans unchecked,[]proved, a non-empty list names the trouble; either failure exits 1. - Tables and columns are compared for
reversed; indexes, constraints, and column defaults are not yet. sustained rehearse --json.sustained migratere-reads the schema after a run when the config module names models and reports the differences left. A report, never a gate.Migrator.drift(models)returns what the models still ask for, one readable line each; objects the models do not declare are left out.diff_snapshots(before, after)andasync_introspect_schema(adapter, dialect)insustained.autogenerate.
Changed
plan --jsonreports each pending migration’sstatementsas objects carrying the SQL and a destructive flag, rather than a count.PendingSummary.statementsbecamePendingSummary.sql.- The generated diff no longer refuses objects the models do not declare, since hand-written migrations create such objects. Drops still need
allow_drops=True, andignore_undeclared=Falserestores the refusal. - The tracking table gained a
generatedcolumn marking rows a model diff wrote, added in place on first use. sustained planprintsrun: sustained migratefor both pending work and drift; a drops-only drift section says migrate does not generate drops.- Schema introspection is one query plan that the blocking cursor and the async adapter each drive, degrading to column-only data where constraint views are missing.
Deprecated
Migrator.sync()raises aDeprecationWarningand delegates toup(models=[...]). It goes away in 3.0.
2.12.0
Added
withGraphFetched()takes a dotted path such as'shows.tickets', one batched query per level withWHERE fk IN (...), so a deeper graph never becomes a query per row. Shared prefixes load once, and an unknown segment raises at build time naming it.- Async eager loading covers link-table relations and dotted paths beyond the first segment. The sync loader split into a planner and an attacher both paths call.
async_transaction()nests through ANSI savepoints, matchingtransaction().
Fixed
- The type stubs describe the join and clause methods the runtime accepts:
whereRaw,havingRaw,fullJoin,crossJoinRelated, and the rest were missing, andouterJoinnever existed. A test compares each stub against the runtime in both directions. LENGTHis registered once; the second registration, carrying the T-SQLLENspelling, overwrote the first.IntrospectedTableandFunctionMetadatadefault their mapping fields to read-only empty mappings, so one instance’s mapping cannot become another’s.
2.11.0
Fixed
repair()no longer rewrites the stored checksum of a changed repeatable, which cancelled the re-run the change had scheduled. Failed-attempt rows are still removed.- A malformed placeholder marker such as
${my-key}or an unclosed${keyraisesValueErrornaming the file, instead of passing through as raw SQL. Applies only when a placeholders mapping is given. rehearse()reads validation state, pending migrations, and applied records inside the advisory lock, so a concurrent migrator cannot apply between the read and the rehearsal.AsyncMigrator.rehearse()refuses a connection in autocommit mode, asMigrator.rehearse()already did.- Tagging an exception with its migration id no longer raises on exception types that reject new attributes.
sustained planprintsrun: Migrator.sync(models)when it finds model drift, whichmigratedoes not close.
Changed
- A targeted
up()no longer runs the repeatables, which may depend on migrations past the target; the next fullup()runs them. - The destructive scan labels a column drop written without the COLUMN keyword, as MySQL allows.
- The refusal message for rehearsing a non-rehearsable dialect mentions
scratch=Truefor library callers. - The docs cover the default dialect’s place on the rehearsable list and scratch databases that keep objects between runs.
2.10.0
Added
sustained rehearseandMigrator.rehearse(): apply every pending migration, run the down steps back down, and roll it all back, so the database ends where it started. Exits 1 when a step failed.AsyncMigrator.rehearse()is the same on an adapter.- Only databases whose schema changes roll back may rehearse: SQLite, Postgres, and DuckDB. Autocommit connections and open
transaction()blocks refuse too. A config module’sget_rehearsal_connection()sends the rehearsal to a scratch database instead. Compiler.begin_transaction_sql()androllback_transaction_sql(): explicit statements the rehearsal uses, since drivers disagree on when a transaction exists. Engines without transactions returnNone.- Config module callbacks around
sustained migrate:before_migrate(connection),after_migrate(connection, applied), andon_error(connection, migration_id, error). Onlymigratecalls them. Migrator.connectionandAsyncMigrator.adapterproperties.
Changed
- A failing statement, a connection that will not open, or a directory that will not load prints as an error line on the command line instead of a traceback.
2.9.0
Added
sustained plan: one screen with the pending migrations, the problemsvalidatewould report, and the drift against the config module’smodels, drops included. Exits 0 current, 2 pending, 1 problems. Note argparse also exits 2 on a usage error.- Destructive labels: the new
sustained.analysismodule labels drops and truncates in the plan, viadestructive_statements(sql)andsummarize(migration, state). The label informs the operator; nothing is blocked. --jsononstatus,validate, andplan: one JSON object on stdout, exit codes unchanged.plan’sdriftis null rather than empty when no models were named, separating “not compared” from “no gap”.
2.8.0
Added
- Repeatable migrations: a
<id>.repeat.sqlfile, orMigration(id, up, repeatable=True), re-runs whenever its checksum changes, for views, functions, and seed data.down()never reverts them, andbaseline()records them at their current checksum. statuses()on both migrators: (id, state) pairs withapplied,pending, andchanged, which the CLIstatuscommand prints.- Placeholders in SQL migration files:
${key}fills fromload_migrations(placeholders=...)or the config module. A missing key raisesValueError,$${escapes, and with no mapping files load untouched. Substitution runs before checksums compute.
Changed
pending()also returns repeatables whose checksum changed, since the nextup()will run them.load_migrations()rejects a.sqlfile only when it matches none of the three suffixes.
2.7.0
Added
- Migrations as SQL files:
load_migrations(directory)pairs<id>.up.sqlfiles with optional<id>.down.sqlfiles, splitting statements at line-ending semicolons. Empty files, orphaned down files, and misnamed.sqlfiles raiseValueError. baseline(target)on both migrators records migrations up to the target as applied without running them, for adopting a database whose schema already matches.Migrator.plan(models, ...): the migrationsync()would generate, without registering or applying it, orNonewhen the schema is current.- A command-line runner: the
sustainedconsole script andpython -m sustaineddrive aMigratorfrom a config module, withstatus,migrate,down,validate,repair,script, andbaseline.
2.6.0
Added
- The tracking table records a sequence number, a SHA-256 checksum of the up statements, execution time, and a success flag; apply order reads from the sequence. Older tables upgrade in place on first use; on Athena the upgrade needs an Iceberg tracking table.
validate()on both migrators raisesMigrationErroron failed attempts, applied ids the migrator does not know, checksum mismatches, and pending migrations ordered before applied ones.repair()deletes rows left by failed attempts and rewrites drifted or null checksums.- On engines without transactions, a failing step writes a failure row that blocks the next
up()until repaired. - Migration runs take an exclusive advisory lock named after the tracking table:
pg_advisory_lockon Postgres,sp_getapplockon MSSQL. Migrationaccepts an explicitchecksumfor callable steps, andmigration_checksum()exposes the value validation compares.applied_records()returns the tracking rows with sequence, checksum, and success flag.
Changed
up()validates before running.validate=Falseskips the checks;allow_out_of_order=Trueaccepts a pending migration ordered before an applied one, which earlier versions applied silently.
Fixed
- The tracking table upgrade backfill touches only the columns the current run added and only rows where they are still null, so a recorded failed attempt survives an interrupted earlier upgrade.
2.5.0
Added
- AWS Athena dialect (
Dialects.ATHENA): Presto’s query behavior with%splaceholders matching pyathena, MERGE upserts on Iceberg tables, and Athena’s type spellings (INT, STRING, DOUBLE, DECIMAL; JSON maps to STRING). TableOptions(location, partitioned_by, properties): storage clauses declared as a model’stableOptions, rendered as PARTITIONED BY, LOCATION, and TBLPROPERTIES on Athena. Other dialects raise when options are set.- Athena DDL:
ADD COLUMNSfor added columns,CHANGE COLUMNfor Iceberg type widenings. Constraints, indexes, renames, nullability changes, RETURNING, and temporary CTAS raiseDialectErrorwith directions. - Migrations on engines without transactions: each step runs bare on Athena, never calling rollback. Both migrators accept
tracking_table_optionsand create the tracking table without constraints on constraint-free engines. - The function registry recognizes Athena wherever it recognizes Presto, including
NOW()and theGETDATE()translation. - Schema diffing normalizes Athena’s STRING type, so tables created from models diff clean.
2.4.0
Added
- Constraint-aware introspection: primary keys, unique constraints, foreign keys, column defaults, and indexes read from SQLite PRAGMA tables or information_schema, with graceful degradation and system schemas filtered.
- Type and nullability changes generate migrations: in-place reversible
ALTER COLUMNon Postgres (withtype_castsUSING hints), MSSQL, and DuckDB; an automatic table rebuild with row copy on SQLite. - Rename hints:
renames={'table.old': 'new'}andtable_renamesproduce reversible RENAME statements (sp_rename on MSSQL) instead of destructive drop-plus-add. - Declared indexes on models via
Index, created with the table and diffed for additions, definition changes, and opt-in drops, all reversible. backfillon ColumnDef: NOT NULL adds and tightenings emit add-nullable, UPDATE, SET NOT NULL, or fold into the SQLite rebuild.- Length and precision changes detected when both sides report them.
- Constraint notes: PK, FK, unique, and default drift reported in the diff, never auto-migrated.
- Offline scripts:
migration_sql()andMigrator.script()render the SQL a run would execute for DBA review. AsyncMigrator: the migration runner on an AsyncAdapter with transactional application and awaited callable steps.
2.3.0
Added
- Schema autogeneration:
diff_schema()introspects the live database and reports missing tables, new columns, extra objects, and changed columns with a readablesummary(). Type comparison round-trips through each dialect’s own mapping. autogenerate()builds aMigrationfrom the diff. Additive steps are reversible; drops needallow_drops=Trueand carry no down step; changed column types block unless ignored; NOT NULL adds without defaults and primary key adds are rejected.Migrator.sync(models): diff, generate, register, and apply in one idempotent call.Migrator.down_to(id)reverts newest-first until the target is the most recent applied migration.- Compilers render
ADD COLUMNandDROP COLUMNstatements, with the T-SQLADDspelling on MSSQL.
2.2.0
Added
- Typed column definitions: models declare
tableColumnswithInteger,BigInteger,String,Text,Boolean,Float,Numeric,Date,Timestamp, andJson, including composite primary keys, defaults, unique constraints, references, and autoincrement. - Model-driven DDL:
create_table_sql(),create_table(), anddrop_table()with per-dialect type mapping and identity syntax. DuckDB and Presto raise for autoincrement. - Migration runner: ordered
Migrationobjects with up/down steps (SQL, statement lists, or callables), a self-creating tracking table, transactional application, stop-after targets, and newest-first reverts.create_table_migration()derives create/drop pairs. ConnectionPool: thread-safe, lazy, bounded pooling for DB-API connections.Model.bind()and all execution entry points accept a pool; transactions pin one checked-out connection to the thread.- Async execution:
arun(),afirst(), andato_dicts()through an adapter interface withDbApiAsyncAdapter,AiosqliteAdapter, andAsyncpgAdapter, plusModel.bind_async()andasync_transaction()with ContextVar pinning.
2.1.0
Added
- Typed predicates:
Model.c.age > 21andcol()build composablePredicateobjects combinable with&,|,~; accepted bywhere()andhaving(). whereRaw()/havingRaw(): raw predicates with?value markers that parameterize like every other clause.Model.transaction()context manager with savepoint nesting;run()defers commits inside a transaction.set_statement_listener()observer with SQL, parameters, and duration for every executed statement.- Upserts:
insert().onConflict(cols).merge()/.ignore(). ON CONFLICT on Postgres/SQLite/DuckDB, MERGE on MSSQL, DialectError on Presto. insert_from()(INSERT … SELECT) andcreate_table_as()(CTAS; MSSQL raises).- Multi-row inserts execute through the driver’s
executemany()when there is no RETURNING clause. - Result formats:
to_dicts(),to_df()(pandas optional),to_arrow()(pyarrow optional). - DuckDB dialect: quoting, native ILIKE, qmark placeholders, upserts, RETURNING, CTAS, QUALIFY.
- Recursive CTEs via
with_(..., recursive=True); MSSQL renders plain WITH. - Set operations:
intersect()andexcept_(). - Analyst clauses:
distinctOn(),groupByRollup(),groupByCube(),groupByGroupingSets(),qualify(). for_update(skip_locked, nowait)row locking on Postgres.total()count helper andcursor_page()keyset pagination.explain(analyze=False)plan inspection.- Through-relation (
ManyToManyRelation) eager loading inwithGraphFetched(). - Per-dialect function name translation:
NOW()renders asGETDATE()on MSSQL and the reverse;LENGTH()renders asLEN()on MSSQL.
2.0.0
Breaking changes
- String arguments to
select_func()and the dynamic function methods are now column references, not string literals. Wrap literal values inLiteral(). - Operators passed to
where()andhaving()are validated against an allowlist; unrecognized operators raiseValueError. top()raisesDialectErroron dialects other than MSSQL. It previously disappeared from the query without warning.- On MSSQL,
limit()andoffset()raiseDialectErrorwhen the query has noORDER BY, because T-SQL rejects OFFSET/FETCH without one. whereILike()compiles toLOWER(col) LIKE LOWER(pattern)on dialects without native ILIKE. Postgres keeps nativeILIKE.- Booleans render as
TRUE/FALSE, or1/0on MSSQL, instead of the Python words. - Duplicate CTE aliases with different definitions raise
ValueErrorinstead of silently keeping the last one. update()anddelete()refuse to render without awhere()clause.- Empty
whereIn()lists raiseValueError. - Column references in WHERE, HAVING, and GROUP BY clauses quote per dialect when they are plain identifier paths.
with_()requires aQueryBuilderand renders it lazily; later changes to the CTE subquery reach the output.- The declared Python floor is now 3.9.
Added
to_sql()returns the statement as(sql, params)with dialect placeholders (?by default,%sfor Postgres).insert(),update(),delete(), andreturning()statement builders.- Query execution:
Model.bind(connection),run(), andfirst()against any DB-API 2.0 connection, with rows hydrated into model instances. withGraphFetched()eager loading for HasMany, HasOne, and BelongsToOne relations.- Class-level column access (
User.id), an optionalcolumnsdeclaration that rejects typo’d column names, and a model registry that resolves stringmodelClassreferences across modules. clone()for branching from a shared base query andpage()for zero-based pagination.- snake_case aliases for every camelCase query method.
- Window functions accept arguments, frame clauses, and ORDER BY directions.
- Select lists accept the
'column AS alias'shorthand. - Comparing a column to
Nonewith=or!=rendersIS NULL/IS NOT NULL.
Fixed
copy.copy,copy.deepcopy, andpickleno longer recurse infinitely on builders and models.- Union members keep their own
ORDER BYandLIMITinstead of dropping them. - CTEs on FROM subqueries and on other CTEs hoist into the top-level
WITHclause instead of rendering invalid nestedWITHstatements. GROUP BYno longer quotes a dotted path as a single identifier.- MSSQL quotes dotted identifier paths, and Presto renders
OFFSETbeforeLIMIT. limit(),offset(), andtop()reject booleans and negative numbers.- Aliased
joinRelated()no longer crashes when the join’storeference has no table prefix. - The deploy script rolls back the version bump when the build fails and pushes the release commit along with its tag.
1.1.0
Added
- Dialect-specific query compilation: a query builds once and compiles for a chosen dialect, starting with the default, PostgreSQL, MSSQL, and Presto compilers.
- A function registry with per-dialect validation:
select_func()and the fluent function methods raiseDialectErrorat build time when the dialect does not support the function.
Changed
- Function rendering moved into the compiler, so every dialect renders function calls through one path.
1.0.2
Fixed
- The type stubs declare the join methods they were missing.
1.0.1
Fixed
- The package include path for the type stubs, so installs get them.
1.0.0
Added
- Type stub files for the builders, packaged with the distribution.
- The deploy script tags each release.
0.0.7
Added
USINGclauses on joins, and subqueries in JOIN ON clauses.- LIKE and NULL checks in WHERE and HAVING clauses.
0.0.6
Added
distinct()on the query builder.avg(),min(), andmax()aggregate methods.Func, for calling any SQL function, andSubquery, for embedding a subquery in the SELECT list.
0.0.5
Added
- A select clause builder with fluent methods for complex select lists.
Column, for marking a value as a column reference rather than a literal.- The expression classes export from the top-level package.
Changed
Anyannotations across the codebase were replaced with specific types.
0.0.4
Added
- ORDER BY, LIMIT, TOP, and OFFSET clauses.
- UNION queries.
- Subqueries in FROM expressions and in conditional clauses, and EXISTS and BETWEEN conditions.
0.0.3
First tagged release. SELECT query building with joins, WHERE, GROUP BY, and HAVING clauses, relation-aware joins through joinRelated(), and a builder split into per-clause components.
Fixed
andWhere()could start a WHERE clause on its own.