Skip to content

Migrations

Migration files are Python, not YAML or JSON, and that is not a stylistic choice: python_type is a Python type, and serialising it to JSON would need a name-to-type registry — a second, worse type system. In .py it is an import and a reference.

Each operation knows how to apply itself and how to undo itself. What an engine cannot do is stopped in the PLAN, with a readable reason, rather than halfway through a deploy.

Where this text comes from

Everything below the headings is generated from the package's own docstrings, on every build.

The headings themselves are written by hand, so this page can fall behind the module — it had, by seven operations. The list that cannot fall behind is the module's own:

rg -n '^class ' src/snakeorm/migration/operations.py

Runners

A migration declares what it comes AFTER, and the loader turns those declarations into one order across packages. A cycle is refused out loud, naming the migrations that close it — picking an order and hoping is the one thing it will not do.

from snakeorm.migration import Migration

class AddOrderTotals(Migration):
    """A migration from another package can be named as a dependency."""

    depends_on = ["billing.0003_add_plans"]
    operations = [...]

Migration dataclass

Migration(
    version: str,
    operations: tuple[SnakeMigrationOperation, ...],
    replaces: tuple[str, ...] = (),
)

A versioned unit of operations (schema and/or data). The order comes from its number.

replaces class-attribute instance-attribute

replaces: tuple[str, ...] = ()

Versions this migration REPLACES (a squash). Empty in a normal migration.

It allows a history to be collapsed without breaking the databases where the originals were already applied: there the squash is marked applied without being executed. See apply.

MigrationRunner

MigrationRunner(driver: SnakeDriver, dialect: SnakeDialect)

Applies/reverts migrations and records the applied ones in snake_migrations.

ensure_tracking_table

ensure_tracking_table() -> None

Creates the tracking table if it does not exist (idempotent).

The DDL itself is tracking_table_ddl, shared with the async runner. It used to live here, and the async one carried its own copy with the MySQL bug this one had already fixed — which is the reason it is a loose function now and not a method on each.

applied_versions

applied_versions() -> set[str]

Returns the set of versions already applied.

apply

apply(migrations: list[Migration]) -> list[str]

Applies the pending migrations in order. Returns the versions applied just now.

Every migration is ATOMIC: its operations and the version record go together. With transactional DDL a failure halfway does a rollback(); without it, how many were applied is reported.

rollback

rollback(migration: Migration) -> None

Reverts a migration (operations in reverse order) and deletes its record.

AsyncMigrationRunner

AsyncMigrationRunner(
    driver: AsyncDriver, dialect: SnakeDialect
)

Applies/reverts migrations over an AsyncDriver, with the same tracking as the sync one.

ensure_tracking_table async

ensure_tracking_table() -> None

Creates the tracking table if it does not exist (idempotent).

applied_versions async

applied_versions() -> set[str]

Returns the set of versions already applied.

apply async

apply(migrations: list[Migration]) -> list[str]

Applies the pending migrations in order. Returns the versions applied just now.

Same semantics as the synchronous one: idempotent, atomic per migration with transactional DDL, and a hard stop if a squash replaces a history that was applied HALFWAY.

rollback async

rollback(migration: Migration) -> None

Reverts a migration (operations in reverse order) and deletes its record.

Autodetection

autodetect

Django-style autogen: rebuilds the state by replaying migrations and diffs it.

replay applies the operations of every migration onto an empty SchemaState -> the state the schema SHOULD have according to the history. autodetect diffs that against the current metadata (the code-first source of truth) and returns the operations for the new migration. Neither snapshots nor reflection: the migration history IS the record of the state.

current_schema

current_schema(
    reg: SnakeRegistry = registry,
    *,
    database: str | None = None,
    include_unmanaged: bool = False,
) -> list[SnakeTableInfo]

The current metadata (code-first source of truth): the tables of the registered models.

Excludes MIRROR models (@snake_db_first): they are not a source of truth, and autogen must not touch them. With database, only those of THAT connection (which avoids creating every table in every DB). include_unmanaged=True returns them too: drift detection uses it (comparing the mirror against the DB), but MIGRATIONS never touch them (the default behaviour).

current_routines

current_routines(
    reg: SnakeRegistry = registry,
) -> list[SnakeRoutineInfo]

The desired routines (code-first source of truth): those declared with snake_function.

current_triggers

current_triggers(
    reg: SnakeRegistry = registry,
) -> list[SnakeTriggerInfo]

The DESIRED triggers: those declared with snake_trigger(...).

replay

replay(migrations: Iterable[Migration]) -> SchemaState

Rebuilds the schema state by applying the migrations' operations in order.

autodetect

autodetect(
    migrations: Iterable[Migration],
    current: Iterable[SnakeTableInfo],
    routines: Iterable[SnakeRoutineInfo] | None = None,
    triggers: Iterable[SnakeTriggerInfo] | None = None,
) -> list[SnakeOperation]

Diffs the state replayed from the history against the current metadata -> new operations.

Resolves FK targets by model name through the global registry. Global order: tables -> FKs -> views (topological) -> functions -> triggers (each one depends on the ones before it).

replay

replay(migrations: Iterable[Migration]) -> SchemaState

Rebuilds the schema state by applying the migrations' operations in order.

diff_schema

diff_schema(
    before: Iterable[SnakeTableInfo],
    after: Iterable[SnakeTableInfo],
    resolve_target: ResolveTarget | None = None,
    resolve_qualified: ResolveTarget | None = None,
    triggers: Iterable[SnakeTriggerInfo] = (),
) -> list[SnakeOperation]

Derives the operations to get from schema before to after.

Order: tables (create/drop/columns) -> FKs -> views (which depend on the tables). Views are not mixed in with columns or FKs; if their definition changes the whole thing is replaced (AlterView).

triggers are the ones the schema ALREADY has — the replayed state's, which is the only place that knows they exist, since SnakeTableInfo has no field for one. They are not diffed here (diff_triggers does that, afterwards): they are handed to any RebuildTable this call emits, because on an engine with no ALTER TABLE ADD CONSTRAINT that rebuild DROPS the table and takes them with it. Filling them in the same call that builds the operation is what keeps a rebuild from coming out headless — nobody has to remember a second step.

THE VIEWS ARE NOT HANDED OVER, and the difference from the triggers is a fact against a guess. A SnakeTriggerInfo has a .table, so "the triggers of this table" is a question the state answers exactly. Nothing says which TABLES a view reads: depends_on is view->view only and is refused for tables on purpose, and a view declared with sql= is raw text. So the generator emits the rebuild bare: the engine refuses it, the migration rolls back whole, and explain_rebuild_failure says to put a DropView before it and a CreateView after it.

render_migration

render_migration(
    version: str,
    operations: Sequence[SnakeMigrationOperation],
    replaces: Sequence[str] = (),
) -> str

Generates the text of a migration file that rebuilds operations when it is imported.

It exposes version, operations and migration: Migration. replaces are the versions that this file SUPERSEDES (a squash); it is written only when there is one (a normal migration does not shift a single byte) and it must be written: the runner needs it so as not to rerun a squash over a DB that is already migrated.

load

load(directory: str | Path) -> list[Migration]

Discovers, validates and loads a directory's migrations, ordered by number.

Returns [] if the directory does not exist or has no migrations. Raises SnakeMigrationError if there are duplicate numbers or gaps in the sequence.

drop_order

drop_order(
    tables: list[SnakeTableInfo],
) -> list[SnakeTableInfo]

Orders tables so the one HOLDING a foreign key is dropped before the one it points at.

Two of the three engines refuse DROP TABLE while a key points at the table — measured: PostgreSQL says other objects depend on it, MariaDB answers error 1451, and only SQLite accepts it and leaves the key dangling. It is not a dialect difference to translate in the emitter: the SQL is correct and what matters is the ORDER.

It is the exact mirror of the creation order the planner already derives from the same FKs, and of topological_view_order, which drops views in reverse of the order it creates them in.

Only edges INSIDE this set count: a key into a table that is not being dropped constrains nothing here. A table pointing at ITSELF (parent_id, the commonest tree there is) imposes no order either — its own edge cannot make it wait for itself. A real cycle between two tables cannot be ordered at all, so it stops and names THE WHOLE LOOP: naming one end sends the reader to look at half of it, and the half they cannot see is the one holding the other key.

Schemas

CreateSchema dataclass

CreateSchema(schema: str)

Creates a schema; its reverse drops it. It goes BEFORE any table using it.

DropSchema dataclass

DropSchema(schema: str)

Drops a schema; its reverse recreates it.

Table operations

CreateTable dataclass

CreateTable(table: SnakeTableInfo)

Creates a table; its reverse drops it.

DropTable dataclass

DropTable(table: SnakeTableInfo)

Drops a table; its reverse recreates it.

RenameTable dataclass

RenameTable(table: SnakeTableInfo, new_name: str)

Renames a table KEEPING its rows; its reverse gives it the old name back.

Written BY HAND, like RenameColumn and for the same reason one level up: the diff sees a rename as CreateTable + DropTable, which is correct SQL and destroys every row in the table. The diff is not taught to guess it — guessing wrong keeps a table somebody asked to destroy, with another table's data inside.

THE OLD NAME IS table.name and there is no second field holding it. Two spellings of one fact are two things that can disagree, and this repository has already paid for a pair broken in half inside the linker; here the table being renamed IS the table this operation carries, so the question does not arise. RenameColumn needs an old_name because a table has many columns and the table alone cannot say which one.

It renames WITHIN a schema. Moving a table to another schema is ALTER TABLE ... SET SCHEMA, a different statement, and Postgres refuses to spell it as a qualified RENAME (measured).

RebuildTable dataclass

RebuildTable(
    before: SnakeTableInfo,
    after: SnakeTableInfo,
    triggers: tuple[SnakeTriggerInfo, ...] = (),
)

Takes a table from one CONSTRAINT shape to another; its reverse takes it back.

AN OPERATION AND NOT A SIDE EFFECT. SQLite has no ALTER TABLE ADD/DROP CONSTRAINT, so changing a CHECK or a foreign key means remaking the table. It is in the file, with a name and both snapshots, so a reader knows which table gets remade and a revert gets the other shape back.

The file stays engine-agnostic: each dialect spells it — the minimal ALTER on Postgres and MySQL, the whole rebuild on SQLite.

THE TWO SNAPSHOTS MAY ONLY DIFFER IN CONSTRAINTS, checked here rather than trusted. A pair disagreeing about a column would apply on SQLite (which recreates from after) and not on Postgres (whose minimal change emits no ALTER COLUMN), leaving two engines on different schemas without a word. Columns have their own operations; renaming has RenameTable.

THE TRIGGERS RIDE IN A THIRD FIELD, and that asymmetry is forced: indexes come back because they live inside the snapshot, and SnakeTableInfo has no triggers. They are filled by the caller that holds the state (diff_schema) and recreated by _remake_table.

THE VIEWS DO NOT TRAVEL: a trigger knows its .table, a view does not. The consequence is translated rather than hidden — SQLite's closing ALTER TABLE ... RENAME TO reparses the schema, so a standing view that READS this table fails the migration whole (error in view <v>: no such table), and explain_rebuild_failure turns that line into the one that says what to write: a DropView before and a CreateView after.

triggers class-attribute instance-attribute

triggers: tuple[SnakeTriggerInfo, ...] = ()

The triggers hanging off this table, which the rebuild has to put back after dropping it.

apply_to_state

apply_to_state(state: SchemaState) -> None

Leaves the after snapshot in the state — and REFUSES to drop a trigger on the floor.

THIS IS WHERE THE QUESTION CAN BE ASKED AT ALL. The operation cannot see the triggers by itself: up_sql gets a dialect and nothing else, and the file that builds it is imported with no state anywhere near it. apply_to_state is the one place a rebuild of ANY provenance — autodetected or written by hand — meets a SchemaState, and replay walks every operation of every migration through it on each makemigrations and each squash.

So a rebuild whose table has triggers the payload does not carry stops the replay and names them, instead of leaving the state believing in triggers the DROP TABLE already ate. The normal path never gets here with the question open: diff_schema receives the state's triggers in the same call that builds the operation.

AlterTableComment dataclass

AlterTableComment(
    table: SnakeTableInfo, previous: str | None
)

Changes an existing table's COMMENT ON TABLE; its reverse restores the previous one.

The COLUMN one is already covered by AlterColumn; this is the TABLE one. On an engine without comments (SQLite) up/down come out empty: the operation exists in the history but emits nothing.

AddColumn dataclass

AddColumn(table: SnakeTableInfo, column: SnakeColumnInfo)

Adds a column to a table; its reverse drops it.

DropColumn dataclass

DropColumn(table: SnakeTableInfo, column: SnakeColumnInfo)

Drops a column from a table; its reverse recreates it (with its original info).

RenameColumn dataclass

RenameColumn(
    table: SnakeTableInfo, old_name: str, new_name: str
)

Renames a column KEEPING its data; its reverse gives it the old name back.

It is written BY HAND, replacing the DropColumn + AddColumn the diff generates (correct but catastrophic: it drops the old column along with its data).

AlterColumn dataclass

AlterColumn(
    table: SnakeTableInfo,
    old: SnakeColumnInfo,
    new: SnakeColumnInfo,
)

Changes an existing column (type/nullable); its reverse undoes the change.

RebuildTable takes a table from one CONSTRAINT shape to another, and that is SQLite's way out: it has no ALTER TABLE ADD/DROP CONSTRAINT and never will, so a CHECK or a foreign key on a table that ALREADY EXISTS can only get there by remaking the table around it — create the new one, copy the rows, drop the old one, rename. The operation names no engine: Postgres and MySQL get the single minimal ALTER TABLE ... ADD CONSTRAINT, SQLite gets the whole rebuild.

It takes two WHOLE SnakeTableInfo snapshots, plus the triggers hanging off the table — the rebuild drops them along with it and owes them back. And it REFUSES a pair that disagrees about anything but CHECKs and foreign keys, naming what disagrees: a difference in columns would apply on SQLite (which recreates the table from after) and not on Postgres (whose minimal ALTER emits nothing for it), leaving the two engines holding different schemas with neither saying a word. Columns keep their own operations, and dropping one a foreign key still holds is NOT this operation: SQLite has no DROP CONSTRAINT to take the key out of the way first either, so that one is a hand-written RunSQL, which is the user's call — see known limits.

from dataclasses import replace

from snakeorm.metadata import (
    SnakeColumnInfo,
    SnakeForeignKeyInfo,
    SnakePrimaryKeyInfo,
    SnakeRelationshipInfo,
    SnakeRelationshipKind,
    SnakeTableInfo,
)
from snakeorm.migration import RebuildTable

tag_id = SnakeColumnInfo(name="id", python_type=int, attr_name="id", autoincrement=True)
parent_id = SnakeColumnInfo(
    name="parent_id", python_type=int, nullable=True, attr_name="parent_id"
)

# The WHOLE table as this migration finds it: on SQLite the rebuild recreates it from `after`, so
# anything left out of the snapshot is structure lost without a word.
tags = SnakeTableInfo(
    name="tags",
    columns=(tag_id, parent_id),
    primary_key=SnakePrimaryKeyInfo(columns=(tag_id,)),
)
parent = SnakeRelationshipInfo(
    name="parent",
    target="Tag",
    kind=SnakeRelationshipKind.TO_ONE,
    foreign_key=SnakeForeignKeyInfo(target="Tag", pairs=(("parent_id", "id"),)),
    target_table="public.tags",
)

operations = [
    RebuildTable(
        before=tags,
        after=replace(tags, relationships=(parent,)),
        triggers=(),
    ),
]

Constraints and indexes

CreateIndex dataclass

CreateIndex(table: SnakeTableInfo, index: SnakeIndexInfo)

Creates an index on an ALREADY existing table; its reverse drops it.

It only appears when the table is already in the state: a NEW table's indexes are emitted by CreateTable.up_sql itself, and duplicating them here would make the migration fail on apply.

DropIndex dataclass

DropIndex(table: SnakeTableInfo, index: SnakeIndexInfo)

Drops an index; its reverse recreates it (with its original info: columns, unique, name).

AddCheck dataclass

AddCheck(table: SnakeTableInfo, check: SnakeCheckInfo)

Adds a CHECK constraint to an ALREADY existing table; its reverse drops it.

A NEW table's checks are emitted by its own CreateTable, just as its indexes are.

DropCheck dataclass

DropCheck(table: SnakeTableInfo, check: SnakeCheckInfo)

Drops a CHECK constraint; its reverse recreates it with its original condition.

AddForeignKey dataclass

AddForeignKey(
    table: SnakeTableInfo,
    relationship: SnakeRelationshipInfo,
    target: SnakeTableInfo,
)

Adds an FK (at the end, after the tables are created); its reverse drops it.

DropForeignKey dataclass

DropForeignKey(
    table: SnakeTableInfo,
    relationship: SnakeRelationshipInfo,
    target: SnakeTableInfo,
)

Drops an FK; its reverse recreates it (with the original target table).

Views, functions and triggers

CreateView dataclass

CreateView(view: SnakeTableInfo)

Creates a view (CREATE VIEW ... AS <def>); its reverse drops it.

DropView dataclass

DropView(view: SnakeTableInfo)

Drops a view (DROP VIEW); its reverse recreates it with its original definition.

AlterView dataclass

AlterView(old: SnakeTableInfo, new: SnakeTableInfo)

Changes a view's definition; its reverse restores the old one.

A changed FILTER is a CREATE OR REPLACE VIEW. A changed PROJECTION is not: no engine's replacement can rename an output column — measured on PostgreSQL, cannot change name of view column "a" to "x" — so the view has to be dropped and made again.

That is decided by comparing the two column lists and NOT by asking the dialect what it supports, because it is not a capability question. PostgreSQL and MySQL both declare Cap.REPLACE_VIEW and both refuse this: Cap answers "can this engine do X", and what is being asked here is "can X express the change". The two agreed until a view's projection moved.

CreateFunction dataclass

CreateFunction(definition: SnakeRoutineInfo)

Creates (or replaces) a routine by emitting its body; its reverse drops it (DROP FUNCTION).

DropFunction dataclass

DropFunction(definition: SnakeRoutineInfo)

Drops a routine (DROP FUNCTION); its reverse recreates it with its original body.

AlterFunction dataclass

AlterFunction(old: SnakeRoutineInfo, new: SnakeRoutineInfo)

Changes a routine (CREATE OR REPLACE with the new body); its reverse restores the old one.

CreateTrigger dataclass

CreateTrigger(definition: SnakeTriggerInfo)

Creates a trigger. Its inverse is dropping it.

up_sql

up_sql(dialect: SnakeDialect) -> list[str]

CREATE TRIGGER ....

down_sql

down_sql(dialect: SnakeDialect) -> list[str]

DROP TRIGGER ....

apply_to_state

apply_to_state(state: SchemaState) -> None

Adds the trigger to the replayed state.

DropTrigger dataclass

DropTrigger(definition: SnakeTriggerInfo)

Drops a trigger. Its inverse is creating it again.

up_sql

up_sql(dialect: SnakeDialect) -> list[str]

DROP TRIGGER ....

down_sql

down_sql(dialect: SnakeDialect) -> list[str]

CREATE TRIGGER ....

apply_to_state

apply_to_state(state: SchemaState) -> None

Removes the trigger from the replayed state.

AlterTrigger dataclass

AlterTrigger(old: SnakeTriggerInfo, new: SnakeTriggerInfo)

Replaces a trigger: it is DROPPED and created again.

With no portable CREATE OR REPLACE TRIGGER, it is done in two steps. The down recreates the OLD one, so undoing gives back exactly the trigger that was there.

up_sql

up_sql(dialect: SnakeDialect) -> list[str]

Drops the old one and creates the new one.

down_sql

down_sql(dialect: SnakeDialect) -> list[str]

Drops the new one and restores the old one.

apply_to_state

apply_to_state(state: SchemaState) -> None

Leaves the NEW trigger in the replayed state.

Escape hatches

RunSQL dataclass

RunSQL(
    up: str | tuple[str, ...],
    down: str | tuple[str, ...] | None = None,
)

RAW data SQL: it runs up's statements, and down's as the reverse (or nothing).

An escape hatch: bare SQL, NOT portable between engines. It fits SnakeOperation (it emits SQL) but it is a DATA migration: apply_to_state is a no-op (it mutates rows, not the abstract schema).

up_sql

up_sql(dialect: SnakeDialect) -> list[str]

up's statements (raw: the dialect does not touch them).

down_sql

down_sql(dialect: SnakeDialect) -> list[str]

down's statements, or [] if no reverse was declared.

apply_to_state

apply_to_state(state: SchemaState) -> None

No-op: a data migration does not change the abstract state's tables.

RunPython dataclass

RunPython(
    forward: Callable[[SnakeSession], None],
    backward: Callable[[SnakeSession], None] | None = None,
)

A DATA operation running Python code with the typed ORM (a SnakeSession).

forward/backward receive a SnakeSession and migrate data with the ORM. They MUST be module-level functions (importable), not lambdas or closures: the renderer writes them by reference. Without backward the migration is not reversible and unrun says so plainly.

run

run(session: SnakeSession) -> None

Applies the data migration: it invokes forward with the session.

unrun

unrun(session: SnakeSession) -> None

Undoes the data migration with backward; without it, raises SnakeMigrationError.

apply_to_state

apply_to_state(state: SchemaState) -> None

No-op: a data migration does not change the abstract state's tables.