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 --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. |
--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. |
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). -
Table rename →
RenameTable(see Rename detection below).
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.