Migrations¶
yara_orm ships a Django/Tortoise-style migration system for evolving your
database schema alongside your models. Migrations are operation-based
and auto-generated: makemigrations diffs your models against the recorded
state and writes a numbered migration file; upgrade and downgrade apply or
revert those files in this async Python ORM.
Crucially, migrations are backend-portable. A migration records operations (create table, add column, …), not raw DDL. The same operations render to the correct SQL for the active dialect at apply time, so one migration set runs unchanged on PostgreSQL, MySQL or SQLite.
Each migration file declares a class Migration(m.Migration) with operations
(and optional dependencies / atomic). Operations are built from live field
objects — CreateModel lists fields={col: Field}, AddField / AlterField
carry a single Field — so a migration reads like your models.
How state is tracked
The target schema is replayed from the migration files on disk
(Django-style) — each file's Migration.operations are applied in order to
rebuild the recorded schema state, which is then diffed against your current
models. Applied migrations are recorded per app in an orm_migrations table,
which is created automatically on first use. Files are sanity-checked at
load time: duplicate numeric prefixes and dependencies that would run
after their dependant print a warning (existing directories keep loading —
number ties run in deterministic file-name order).
SQLite table rebuilds and atomicity
On SQLite, an AlterField (or a constraint change) rebuilds the table using
SQLite's official recipe, which must toggle PRAGMA foreign_keys outside a
transaction. An atomic migration containing a rebuild therefore commits
the operations preceding the rebuild before running it — keep schema
rebuilds in their own migration file when you need strict all-or-nothing
behaviour around other operations.
Idempotent by default
makemigrations emits the idempotent analog of each operation
(CreateModelIfNotExists, AddFieldIfNotExists, RemoveFieldIfExists,
AddIndexIfNotExists, …). A column whose definition changed is emitted as
AlterField automatically (PostgreSQL alters in place, MySQL restates the
definition with MODIFY COLUMN; SQLite rebuilds the table, preserving rows,
referencing rows, indexes and constraints).
Atomic migrations (the default) are all-or-nothing: a failure rolls the
whole file back, so re-running is always safe. For atomic = False
migrations, re-running a half-applied file is safe only for the guarded
*IfExists / *IfNotExists operations — RenameField, constraint changes
and concurrent index builds are not guarded (SQLite and MySQL cannot guard
add/drop column at all, and MySQL has no CREATE INDEX IF NOT EXISTS
either).
What makemigrations auto-detects
Beyond create/drop table and add/drop/alter column, the diff also detects:
- Renames — a field renamed with an unchanged type becomes a single
RenameField(preserving the data) instead of a destructive drop + add. Detection is deliberately conservative: only an unambiguous pairing (one removed and one added column of identical definition) is treated as a rename; ambiguous sets fall back to drop + add with a printed hint to hand-writeRenameFieldif a rename was intended. - Column definition changes — type, nullability, single-column
unique, a database-sidedefault, a foreign key'son_delete, and a referenced primary key's type change all emitAlterField. - Composite indexes — adding/removing a
Meta.indexesentry (including partialIndex(..., condition=...)indexes) emitsAddCompositeIndex/RemoveCompositeIndex. - Named constraints — adding/removing a named
UniqueConstraint/CheckConstraintinMeta.constraintsemitsAddConstraint/RemoveConstraint. - Many-to-many fields — adding/removing an M2M field creates/drops its join table.
Generated foreign keys are stamped with their resolved target
(m.resolved_fk(...) records the target table, primary-key column and
type), so an old migration replays correctly even after the target model
is deleted or its primary key changes.
The CLI¶
Run the tool as a module:
Global options¶
These flags belong to the top-level parser, so they come before the subcommand:
| Flag | Default | Purpose |
|---|---|---|
--dir |
migrations |
Migrations directory. |
--app |
models |
App/label recorded against migrations in orm_migrations. |
--db |
(none) | Database URL; opens a connection for commands that touch the DB. |
--models |
(empty) | Comma-separated model modules to import (and resolve relations) before running. |
--allow-destructive |
off | Let makemigrations write a diff that would drop every recorded table (normally refused as a sign --models was forgotten). |
Order matters
--dir, --app, --db, and --models must appear before the
subcommand. Subcommand flags (like --name or --empty) appear after
it. For example:
init¶
Create the migrations directory (and its __init__.py) if missing.
makemigrations¶
Generate a migration from the model diff. Takes --name (a label for the file)
and --empty (write a migration with no operations, for hand-written data
migrations). With no detected changes, it prints no changes detected.
python -m yara_orm --models myapp.models makemigrations --name add_age
python -m yara_orm --models myapp.models makemigrations --empty --name backfill
upgrade [version]¶
Apply pending migrations, recording each in orm_migrations. An optional
positional version stops after that migration; omit it to apply everything.
The target may be the full name (0002_add_age) or just the numeric prefix
(0002); an unknown target errors before anything is applied, listing the
available migrations.
python -m yara_orm --db postgres://localhost/app --models myapp.models upgrade
python -m yara_orm --db postgres://localhost/app --models myapp.models upgrade 0002_add_age
downgrade [version] [--steps N]¶
Revert applied migrations. By default reverts --steps 1 (the most recent
migration). An optional positional version reverts everything after that
migration and takes precedence over --steps. Like upgrade, the target may be
a full name or a numeric prefix, and an unknown target errors up front.
python -m yara_orm --db postgres://localhost/app --models myapp.models downgrade --steps 2
python -m yara_orm --db postgres://localhost/app --models myapp.models downgrade 0001_initial
history¶
List applied migrations for the app, with their timestamps.
heads¶
List every on-disk migration and whether it has been applied ([x] / [ ]).
sqlmigrate <version> [--backward]¶
Print a migration's SQL without running it. Pass --backward to render the
reverse SQL. The version argument is required here.
python -m yara_orm --models myapp.models sqlmigrate 0001_initial
python -m yara_orm --models myapp.models sqlmigrate 0001_initial --backward
Backend-portable SQL preview¶
Because operations render per dialect, sqlmigrate shows the same migration
as different DDL depending on the --db URL. Choose a backend by pointing
--db at PostgreSQL, MySQL or SQLite:
The migration file is identical in both cases — only the rendered SQL differs.
Programmatic API¶
The same workflow is available through MigrationManager, exported from
yara_orm:
import asyncio
from yara_orm import MigrationManager, YaraOrm
from myapp.models import User, Post
async def main() -> None:
await YaraOrm.init("sqlite:///app.db")
manager = MigrationManager(
directory="migrations",
app="myapp",
models=[User, Post], # defaults to every registered model
)
manager.init() # ensure the directory exists
filename = manager.make_migrations(name="initial") # -> "0001_initial.py" or None
applied = await manager.upgrade() # -> ["0001_initial"]
for row in await manager.history(): # [{"name", "applied_at"}, ...]
print(row["name"], row["applied_at"])
for row in await manager.heads(): # [{"name", "applied"}, ...]
print(row["name"], row["applied"])
sql = manager.sqlmigrate("0001_initial") # list[str], no execution
await manager.downgrade(steps=1) # -> reverted names
await YaraOrm.close()
asyncio.run(main())
| Method | Signature | Notes |
|---|---|---|
init() |
init() -> None |
Create the directory and __init__.py. |
make_migrations() |
make_migrations(name=None, empty=False) -> str \| None |
Returns the new filename, or None when there are no changes. |
upgrade() |
await upgrade(target=None) -> list[str] |
Applies pending migrations up to an optional target. |
downgrade() |
await downgrade(steps=1, target=None) -> list[str] |
target reverts down to that migration and wins over steps. |
history() |
await history() -> list[dict] |
Applied migrations with applied_at. |
heads() |
await heads() -> list[dict] |
All on-disk migrations with an applied flag. |
sqlmigrate() |
sqlmigrate(name, backward=False) -> list[str] |
Render SQL without executing it. |
Async surface
upgrade, downgrade, history, and heads touch the database and are
coroutines — await them. init, make_migrations, and sqlmigrate are
synchronous (the last renders SQL but does not run it).
Operations reference¶
A migration file is plain Python: a class Migration(m.Migration) with an
operations list (and optional dependencies / atomic), built from
yara_orm.migrations (imported in generated files as
from yara_orm import migrations as m, alongside from yara_orm import fields).
| Operation | Purpose |
|---|---|
CreateModel(table, fields, composite_pk=None, composite_indexes=None, constraints=None) |
Create a table from a {column: Field} set (columns, pk, foreign keys, indexes, named constraints). |
DeleteModel(table, fields, composite_pk=None) |
Drop a table (keeps its fields so it can be reversed). |
AddField(table, name, field) |
Add a column from a field object. |
RemoveField(table, name, field) |
Drop a column (keeps the field so it can be reversed). |
AlterField(table, name, field, old) |
Change a column's definition (PostgreSQL in place, MySQL in place via MODIFY COLUMN; SQLite rebuilds the table, keeping rows, FK-referencing rows, indexes and constraints). |
AddIndex(table, column) |
Create an index on a single column. |
RemoveIndex(table, column) |
Drop a single-column index. |
AddCompositeIndex(table, name, columns, condition=None) |
Create a multi-column (optionally partial) index. |
RemoveCompositeIndex(table, name, columns, condition=None) |
Drop a multi-column index. |
RunSQL(sql, reverse_sql=None) |
Run literal SQL forward and, optionally, its reverse. |
RunPython(forward, backward=None) |
Run async Python callables (hand-written migrations only). |
CreateExtension(name) |
CREATE EXTENSION IF NOT EXISTS <name> on PostgreSQL; a no-op on MySQL and SQLite (unlike RunSQL, it renders per dialect). Reverse is empty — the extension is never dropped. |
makemigrations generates the idempotent analogs of the schema operations —
CreateModelIfNotExists, DeleteModelIfExists, AddFieldIfNotExists,
RemoveFieldIfExists, AddIndexIfNotExists, RemoveIndexIfExists,
AddCompositeIndexIfNotExists, RemoveCompositeIndexIfExists — plus
AlterField, RenameField and constraint add/remove. For online index builds on PostgreSQL, hand-written migrations can
use AddIndexConcurrently, AddUniqueIndexConcurrently or
RemoveIndexConcurrently with atomic = False (those builds cannot run inside a
transaction). RunSQL and RunPython are for hand-written --empty migrations.
Custom field kinds registered with
register_field_kind() participate like built-ins: the
field is serialised as fields.<ClassName>(...) (or the registration's custom
source), a type_params change diffs to AlterField, and when the kind
declares requires_extension, makemigrations prepends the matching
m.CreateExtension(...) operations to any migration that creates or retypes
such a column (idempotent — a re-run with no column changes emits nothing).
A generated initial migration for a User and a related Post looks like:
from yara_orm import fields
from yara_orm import migrations as m
class Migration(m.Migration):
atomic = True
dependencies = []
operations = [
m.CreateModelIfNotExists(
"user",
fields={
"id": fields.IntField(pk=True),
"name": fields.CharField(max_length=100),
},
),
m.CreateModelIfNotExists(
"post",
fields={
"id": fields.IntField(pk=True),
"title": fields.CharField(max_length=200, index=True),
"author_id": fields.ForeignKeyField("User"),
},
),
]
Renames and constraints¶
makemigrations auto-detects a column rename (a removed column and an added
one with an identical definition) and emits a single RenameField, preserving
the data. Detection requires the pairing to be unambiguous — with several
same-definition candidates, or when the type also changed, it falls back to
drop + add (with a hint printed when a hand-written RenameField may be what
you want). Renaming Meta.table is likewise not auto-detected: the diff would
be a destructive drop + create pair, so the generated file carries a prominent
# WARNING: comment suggesting RenameModel(old, new) instead. Table and
index renames are hand-written:
| Operation | Purpose |
|---|---|
RenameModel(old, new) |
Rename a table. |
RenameField(table, old, new) |
Rename a column (auto-detected, or hand-written). |
RenameIndex(table, column, old_name, new_name, unique=False) |
Rename an index (PostgreSQL and MySQL in place; SQLite drops/recreates). |
Named constraints in Meta.constraints are diffed automatically (added/removed
constraints emit AddConstraint / RemoveConstraint). You can also manage them
by hand — build a constraint with UniqueConstraint(fields=[...], name=...) or
CheckConstraint(check="...", name=...) and use
AddConstraint / RemoveConstraint / RenameConstraint:
from yara_orm import migrations as m
class Migration(m.Migration):
dependencies = ["0001_initial"]
operations = [
m.RenameField("user", "name", "full_name"),
m.AddConstraint(
"user",
m.UniqueConstraint(fields=["full_name"], name="uq_user_full_name"),
),
]
Constraint changes across backends
AddConstraint / RemoveConstraint / RenameConstraint use
ALTER TABLE … CONSTRAINT on PostgreSQL (in place). MySQL also adds
and drops constraints in place, but has no RENAME CONSTRAINT —
RenameConstraint raises UnSupportedError there (drop and re-add
instead). SQLite has no
such syntax, so on SQLite these rebuild the table with the new constraint
set — data, indexes and other constraints preserved. (A table the migration
system has no recorded state for — e.g. one created via RunSQL — still
raises UnSupportedError on SQLite.) Give every constraint a name so the
operation can be reversed on downgrade.
Data migration with --empty¶
Start from a blank migration:
Then fill in operations. With raw SQL via RunSQL (give reverse_sql to make
it reversible):
from yara_orm import migrations as m
class Migration(m.Migration):
dependencies = ["0001_initial"]
operations = [
m.RunSQL(
"UPDATE users SET active = TRUE WHERE active IS NULL",
reverse_sql="UPDATE users SET active = NULL WHERE active = TRUE",
),
]
Or with async Python via RunPython — handy for ORM-driven data changes
(backward is optional):
from yara_orm import migrations as m
from myapp.models import User
async def seed() -> None:
await User.objects.create(name="admin")
async def unseed() -> None:
await User.objects.filter(name="admin").delete()
class Migration(m.Migration):
dependencies = ["0001_initial"]
operations = [
m.RunPython(seed, unseed),
]
Reversibility
RunPython runs backward on downgrade and RunSQL runs reverse_sql.
If you omit them, the operation simply does nothing when reverted — leave a
note so reviewers know the step is one-way.
Typical workflow¶
- Edit your models — add a field, a model, or an index.
- Generate the migration:
- Review the generated
NNNN_add_age.pyfile and itsoperations. Optionally preview the SQL withsqlmigrate. - Apply it:
- Revert if needed: