Migrations¶
dorm's migration system follows the same pattern Django shipped: each
migration is a Python file with a list of Operation objects that
describe a single forward step. The autodetector compares your model
state to the latest migration and writes the diff for you.
The day-to-day loop¶
# 1. Edit your models
# 2. Generate a migration
dorm makemigrations
# 3. Review the SQL it would emit (optional but recommended)
dorm migrate --dry-run
# 4. Apply
dorm migrate
Each migration file lives in <app>/migrations/000N_<name>.py and is
applied in order. dorm records applied migrations in a
dorm_migrations table inside your database, so re-running migrate
is always safe.
What makemigrations detects¶
- New / removed models →
CreateModel/DeleteModel - New / removed columns →
AddField/RemoveField - Field option changes (max_length, null, default, ...) →
AlterField - Renamed models / fields →
RenameModel/RenameField(asks for confirmation when a remove-then-add is ambiguous) - New / removed
Meta.indexes→AddIndex/RemoveIndex
The detector runs in pure Python over the model _meta registry — no
database call needed.
Empty migrations for data work¶
Produces a stub with RunPython and RunSQL you can fill in:
from typing import Any
from dorm.migrations.operations import RunPython
def fill_slugs(app_label: str, registry: dict[str, Any]) -> None:
Article = registry[f"{app_label}.Article"]
for a in Article.objects.filter(slug=""):
a.slug = slugify(a.title)
a.save(update_fields=["slug"])
class Migration:
dependencies = [("blog", "0003_add_slug")]
operations = [RunPython(fill_slugs, reverse_code=RunPython.noop)]
RunPython callable contract¶
dorm passes exactly two positional arguments to every callable
you hand to RunPython(code=, reverse_code=). Type both of them so
your editor catches mistakes before you run the migration:
| Position | Name | Type | What it is |
|---|---|---|---|
| 1 | app_label |
str |
The app label the migration belongs to (e.g. "blog"). Use it to build keys for registry instead of hard-coding the app name — lets the same callable be reused across forks of an app. |
| 2 | registry |
dict[str, type[dorm.Model]] |
The live model registry. Look up classes by either the bare class name (registry["Post"]) or the app-qualified key (registry["blog.Post"] — preferred, unambiguous when two apps declare the same name). |
What you don't get (intentional differences vs Django):
- No
connection/schema_editorargument. If you need raw SQL inside a Python step, fetch the connection yourself:
from dorm.db.connection import get_connection
get_connection().execute("UPDATE blog_post SET ...", [...])
Most data-migration code shouldn't reach for this — Model.objects.filter(...).update(...)
covers the common case and is portable.
- No "historical" model. dorm hands you the current model
class, not a frozen snapshot of how the model looked at this point
in the migration chain. The implication: a callable that references
a column dropped in a later migration will break if you re-run
history from scratch. Mitigation — keep
RunPythonsteps small, scope them tightly to the columns they touch, and place them right after the schema migration that introduced those columns. If you need to be defensive against future schema changes, write the data step asRunSQLinstead.
reverse_code=¶
Always pass it. RunPython requires a reverse callable to be
considered reversible by dorm migrate <app> <target>; a forward
step without one will run, but the migration will refuse to roll
back and you'll be left with the data half of a partially-undone
migration. Two patterns:
- A real undo function, with the same
(app_label, registry)signature, that reverses what the forward step did (e.g. clears the column the forward step backfilled). RunPython.noop— a built-in callable (matching dorm's contract) you pass when the forward step has no meaningful inverse. The classic case: a one-shot data backfill that tolerates being undone by simply leaving the rows in place.
dorm migrate targets¶
dorm migrate # apply everything pending
dorm migrate blog # only the blog app
dorm migrate blog 0005 # forward or roll back to 0005
dorm migrate blog 0005_add_index # name prefix also works
dorm migrate blog zero # roll back every migration
Rollback runs the operations in reverse using each operation's
backwards() method. RunPython requires a reverse_code= argument
to be reversible.
--dry-run: preview before deploying¶
Prints the exact SQL each pending migration would execute, without
touching the database and without recording the migration as applied.
The recorder is not updated — your next dorm migrate still sees
the same set as pending. Use this as a pre-deploy review step on
production schemas.
dorm showmigrations¶
Crossed boxes are applied; empty boxes are pending. Useful for spotting out-of-order or never-applied migrations after a long-lived branch merges.
Resolving parallel branches (makemigrations --merge)¶
When two feature branches each land their own migration on top of
the same parent, merging them back to main produces a forked
migration graph: two leaves both reference 0001_initial and
neither references the other, so the loader can no longer
linearise application order.
The new file declares dependencies = [("blog", "0002_branch_a"),
("blog", "0003_branch_b")] and carries no operations — it only
re-points the graph's tip. Safe to wire into CI: a no-op when the
graph is already linear (prints "Nothing to merge."). See the
CLI reference for the full flag list.
Squashing¶
After a year of small migrations the chain gets long. squashmigrations
collapses a range into a single file:
Produces blog/migrations/0042_squashed.py with replaces = [...]
listing the originals. Once every environment has applied 0042, you can
delete the originals and the squashed file becomes the new starting
point.
Schema drift detection¶
Compares the live database schema (column names + types pulled from
information_schema / pragma) against what your models expect.
Reports drift like:
- columns the model declares but the DB lacks (forgotten migration)
- columns the DB has but the model doesn't (hand-edited table)
- type mismatches (someone ran
ALTER TYPEoutside the migration tool)
Exits non-zero on drift, so you can wire it into CI or a pre-deploy gate. It does not fix anything — its job is to tell you.
Concurrency: advisory locks¶
dorm migrate takes a PostgreSQL advisory lock (pg_advisory_lock)
before applying anything, so two CI workers racing each other won't
double-apply or corrupt the recorder. SQLite serializes through file
locking, which has the same effect for small dev setups.
Manual migrations: RunPython + RunSQL together¶
When a single migration mixes raw SQL with a Python data step,
declare both inside operations. The RunPython callables follow
the same contract documented in
Empty migrations for data work
above — (app_label: str, registry: dict[str, Any]) -> None.
from typing import Any
from dorm.migrations.operations import RunPython, RunSQL
def backfill_slug_lower(app_label: str, registry: dict[str, Any]) -> None:
"""Forward step: nothing to backfill — the index reads the column live."""
return None
def clear_slug_overrides(app_label: str, registry: dict[str, Any]) -> None:
"""Reverse step: undo any data side-effect the forward did."""
Post = registry[f"{app_label}.Post"]
Post.objects.filter(slug__isnull=False).update(slug="")
class Migration:
atomic = False # required for CREATE INDEX CONCURRENTLY
dependencies = [("blog", "0007_add_slug")]
operations = [
RunSQL(
"CREATE INDEX CONCURRENTLY blog_post_slug_lower ON blog_post (LOWER(slug));",
reverse_sql="DROP INDEX IF EXISTS blog_post_slug_lower;",
),
RunPython(backfill_slug_lower, reverse_code=clear_slug_overrides),
]
RunSQL accepts a single statement or a list. For things like
CREATE INDEX CONCURRENTLY — which cannot run inside a
transaction — set atomic = False at the class level so the
executor skips the per-migration atomic wrap.
Common pitfalls¶
- Forgetting
null=Trueon a new field: dorm refuses to add aNOT NULLcolumn without a default to a non-empty table. Either give it a default, or split into two migrations: add nullable, then backfill, then alter to NOT NULL. - Renaming a model: dorm asks "did you rename X to Y? [y/N]". Answering "no" creates remove + add, which drops the table — re-read before pressing y.
- Editing an applied migration: don't. The recorder hashes the
content; if you really must, also delete the row from
dorm_migrationson every environment.
Zero-downtime migrations (2.1+)¶
Three operations help you avoid AccessExclusiveLock on hot tables:
AddIndex(..., concurrently=True)emitsCREATE INDEX CONCURRENTLYon PostgreSQL. Must be the only DDL in its migration file (the executor needs to skip the surrounding atomic, sinceCONCURRENTLYcannot run in a transaction).SetLockTimeout(ms=...)sets PG'slock_timeoutfor the migration window so any DDL that can't acquire its lock fast enough fails loudly instead of blocking writers indefinitely.ValidateConstraint(table=, name=)runsALTER TABLE ... VALIDATE CONSTRAINT— the second half of the canonicalNOT VALID+VALIDATEpattern for adding FKs / CHECKs to large tables without anAccessExclusiveLock.
Constraints and generated columns¶
Meta.constraints accepts CheckConstraint,
UniqueConstraint(condition=…, deferrable=…, include=…) (3.1+
adds the deferrable + include keywords) and
ExclusionConstraint (3.1+, PostgreSQL only). The autodetector
emits AddConstraint / RemoveConstraint.
GeneratedField declares a database-computed column (PG ≥ 12,
SQLite ≥ 3.31).
Migration ops added in 3.1¶
| Operation | Effect |
|---|---|
SeparateDatabaseAndState(database_operations=, state_operations=) |
Apply a parallel pair of ops — one updates state, the other runs DDL. Useful when the autodetector's understanding diverges from the real database |
AlterModelOptions(name, options=) |
Update Meta options that don't require DDL (ordering, verbose_name, permissions, default_manager_name, base_manager_name). State-only |
AlterModelTable(name, table=) |
Rename the underlying db_table — emits ALTER TABLE old RENAME TO new |
AlterModelManagers(name, managers=) |
Track Meta.managers changes. Pure state — managers live in Python only |
CLI extras in 3.1¶
dorm migrate --run-syncdb— create tables for INSTALLED_APPS with no migrations directory.dorm migrate --prune— drop recorder rows for migration files that no longer exist (e.g. aftersquashmigrations). No DDL.dorm sqlmigrate <app> <name> [--backwards]— render a migration's SQL without applying it.
Operations added in 4.0¶
Zero-downtime DDL (PostgreSQL)¶
| Op | What it does |
|---|---|
AddFieldOnline(model, name, field, *, set_not_null_now=False) |
ADD COLUMN nullable; no rewrite. Follow with backfill + SetNotNullOnline |
BackfillBatch(table, *, update_sql, pk_column='id', batch_size=10_000, sleep_seconds=0) |
Chunked backfill by PK range. Each batch in its own tx |
SetNotNullOnline(model, column) |
CHECK (col IS NOT NULL) NOT VALID + VALIDATE + SET NOT NULL. No rewrite on PG ≥ 12 |
See Online migrations for the end-to-end recipe.
Materialised views (PG-only)¶
| Op | What it does |
|---|---|
CreateMaterializedView(name, sql, *, with_data=True, if_not_exists=False) |
CREATE MATERIALIZED VIEW |
RefreshMaterializedView(name, *, concurrently=False) |
REFRESH MATERIALIZED VIEW [CONCURRENTLY] |
DropMaterializedView(name, *, reverse_sql='', if_exists=True) |
DROP MATERIALIZED VIEW. Reversible if reverse_sql is supplied |
Declarative partitioning (PG ≥ 11)¶
| Op | What it does |
|---|---|
CreatePartitionedTable(name, *, columns_sql, method, key, if_not_exists=False) |
CREATE TABLE ... PARTITION BY <RANGE\|LIST\|HASH> (key) |
CreatePartition(parent, name, *, for_values, if_not_exists=False) |
CREATE TABLE ... PARTITION OF <parent> FOR VALUES <expr> |
AttachPartition(parent, name, *, for_values) / DetachPartition(...) |
ALTER TABLE ... ATTACH/DETACH PARTITION |
Native PostgreSQL ENUM types¶
| Op | What it does |
|---|---|
CreatePGEnum(name, values) |
CREATE TYPE name AS ENUM (…) |
DropPGEnum(name, *, reverse_values=None) |
DROP TYPE. Reversible if reverse_values is supplied |
AddPGEnumValue(type_name, value, *, before=None) |
ALTER TYPE ... ADD VALUE. Irreversible (PG has no DROP VALUE) |
Pair with EnumField(native=True, type_name=...) — the field emits
the type as its db_type.
Functional GIN index for full-text search¶
dorm.search.search_index(table, *fields, name=, config='english')
renders the CREATE INDEX ... USING GIN ON (to_tsvector(...)) SQL
ready to drop into RunSQL: