Managed migrations
The declare → migrate → change → migrate cycle, on by default.
Declare or change a model; umbral diffs the current state against the latest
snapshot and writes a migration file. migrate applies pending files in
order, recording each one in the umbral_migrations tracking table. The loop
works from the first milestone that has models.
Register a model
App::builder().model::<T>() puts the model under the implicit "app"
plugin's migration registry. Plugin-contributed models go through the
Plugin::models() method instead - same registry, different source.
App::builder() .settings(settings) .database("default", pool) .model::<Post>() .build()?;Write the first migration
From your project root:
cargo run -- makemigrations# -> Wrote migrations/app/0001_create_post.json cargo run -- migrate# -> Applied 1 migration(s) cargo run -- showmigrations# -> # plugin: app# -> [X] app/0001_create_postThe migration file is JSON: an ordered operation list plus a snapshot of
every registered model after the migration runs. Future makemigrations
calls diff against the latest snapshot, not the live database.
CLI reference
Every migration command runs through the binary's argv dispatch (umbral-cli). The three you'll use day-to-day, plus their flags:
makemigrations
Diff every registered model against its latest snapshot and write one new file per plugin that has changes.
cargo run -- makemigrationsNo flags. Returns no changes detected and exit 0 when every plugin's diff is empty. Output paths land at migrations/<plugin>/<sequence>_<suffix>.json - the sequence is zero-padded so lexical sort matches the apply order.
migrate
Apply every pending migration in topological order. Pre-checks for drift (rows in the tracking table whose files are missing on disk) and errors out before running anything if it finds critical drift.
cargo run -- migrate # apply all pendingcargo run -- migrate --allow-drift # proceed past orphan tracking rowscargo run -- migrate --allow-destructive # permit DROP TABLE / DROP COLUMN operationscargo run -- migrate --fake <plugin>/<name> # mark one migration applied without running SQLcargo run -- migrate --fake-initial # for each plugin, fake-apply 0001 when its tables already exist| Flag | What it does | When to reach for it |
|---|---|---|
--allow-drift | Logs each missing-on-disk migration as a warning and proceeds. The orphan rows stay in umbral_migrations. | You deleted a migration file and the schema change it represented is already in the DB. |
--allow-destructive | Permits pending migrations that DROP a table/column (destroy rows) to apply. Without it, migrate refuses and lists the destructive ops. | You genuinely intend to drop the table/column — e.g. after an expand-contract removal. |
--fake <plugin>/<name> | Inserts a tracking row for the named migration without executing its SQL. | A schema change was applied out-of-band (psql, raw script) and you're reconciling state. |
--fake-initial | For every plugin, if the tables the 0001_* migration would create already exist, mark it applied without SQL. | Adopting a legacy database - paired with inspectdb. |
A removed .model:: registration (or a dropped plugin / feature flag) auto-generates a DropTable, and the routine makemigrations && migrate loop would otherwise drop that table and every row in it with no confirmation. migrate now refuses to apply any pending DROP TABLE / DROP COLUMN / DROP M2M and prints each one; re-run with --allow-destructive only once you've confirmed the drop is intended. If it wasn't, restore the model registration and re-run makemigrations.
Full drift-recovery walk-throughs (lost-file scenario, legacy-database scenario, the four [X] / [ ] / [!] / [?] state markers) live on the Recovering from migration drift page.
showmigrations
List every plugin's migrations with a four-state marker ([X] applied, [ ] pending, [!] applied-but-missing, [?] out-of-order):
cargo run -- showmigrations# -> # plugin: app# -> [X] app/0001_create_post# -> [X] app/0002_add_post_slug# -> [!] app/0003_add_post_tag # tracking row exists, file gone# -> [ ] app/0004_add_post_author # genuinely pendingNo flags. The footer line reports the pending count.
The [!] and [?] markers are the framework's way of surfacing the drift conditions documented on the Recovering from migration drift page. The first time you see one, run showmigrations to see the full picture before reaching for --allow-drift / --fake.
Change a model
Add a field, drop a field, flip a nullable. Re-run makemigrations and the
diff produces the right operation:
-
New field →
AddColumn -
Removed field →
DropColumn -
Nullable flip →
AlterColumn(rendered via the SQLite table-recreation dance: CREATE _umbral_new, INSERT SELECT, DROP, RENAME - Postgres uses nativeALTER COLUMN ... SET NOT NULL/DROP NOT NULL). -
Safe type change →
AlterColumn. The engine keeps a whitelist of data-preserving casts:- every scalar →
Text(stringify is lossless) - integer widening (
SmallInt→Integer→BigInt) - float widening (
Real→Double) BigInt↔ForeignKey(storage-identical)
Postgres rendering emits
ALTER COLUMN <col> TYPE <new_type> USING <col>::<new_type>; SQLite uses the same table-recreation dance, relying on its dynamic typing to read the existing bytes back under the new column affinity. - every scalar →
-
Unsafe type change →
UnsafeAltererror.Text→BigInt, narrowing integer casts, format-dependent transitions (Text→Date,Text→Uuid) are all rejected because the cast can fail at runtime on existing data. Hand-write the migration with an explicit data-preserving step. -
PK flip →
UnsafeAlter(PK rebuild is its own dance, not shipped yet). -
New / removed
#[umbral(unique_together = ...)]group →AddIndex { unique }/DropIndex— even when no column changed. The group renders as a namedCREATE UNIQUE INDEX "uniq_<table>_<cols>"(not an inlineUNIQUE(...)constraint) precisely so it can be dropped by name later; adding aunique_togetherfails at apply time if existing rows already violate it (de-duplicate first). -
New / removed multi-column
#[umbral(indexes = ...)]group →AddIndex/DropIndex, namedidx_<table>_<cols>. -
Table rename →
RenameTable(see Rename detection below).
Concurrent deploys are safe (Postgres)
When several replicas deploy at once and all run migrate against the same Postgres database, they don't race the same DDL. Each migrate run takes a session-level pg_advisory_lock (keyed per pool alias / tenant schema, so unrelated databases still migrate in parallel) before reading the applied-set and applying anything. The first migrator applies the pending migrations and records them; the others wait, then see them already applied and do nothing — instead of two replicas both issuing CREATE TABLE and one aborting the deploy with relation already exists.
The wait is bounded by UMBRAL_MIGRATION_LOCK_TIMEOUT_SECS (default 300); a crashed migrator's lock releases automatically when its connection drops. On SQLite (single-writer, typically single-process migration) the lock isn't taken — the file lock already serializes writers.
Rename detection
When makemigrations sees a model disappear from the snapshot and a new model appear, it tries to determine whether the change is a rename (one DDL statement) or a genuine drop-and-create (two). The autodetector runs two passes:
First pass - struct-name match. Model::NAME (the Rust struct name) is the stable identity key. If the same struct name appears in both the previous snapshot and the current model set but the SQL table name changed, the autodetector emits RenameTable { from, to } and zero DropTable / CreateTable operations. This is the common case when you opt into the #[umbral(plugin = "blog")] namespace attribute on an existing model.
# Before: Post, table = "post"# After: Post, table = "blog_post" (#[umbral(plugin = "blog")] added)# → makemigrations emits: RenameTable { from: "post", to: "blog_post" }Second pass - column-shape match. For models that are unpaired after the first pass (different struct names on both sides), the autodetector checks whether the column shapes are bit-identical (same column names, types, nullable, FK target). If they match, it emits RenameTable and prints a warning to stderr so you can verify the intent:
umbral makemigrations: rename detected (column-shape match): `foo` -> `bar` - please verify this is a rename and not a coincidental column-shape match between two unrelated modelsNo match. If neither pass pairs a drop with a create, the autodetector falls back to plain DropTable + CreateTable.
Both passes produce the same DDL: ALTER TABLE "from" RENAME TO "to" on both SQLite and Postgres.
The migration tracking table records (plugin, name) of each applied migration - it is not affected by a table rename inside the migration itself.
What's next
- Port an existing database into the same migration loop.
- Plugin-namespaced tables explains the
#[umbral(plugin = "...")]attribute that triggers first-pass rename detection. - The full operation catalogue and the deferred items (index ops, RunSql, interactive rename prompts) live in
docs/specs/06-migration-engine.md.