This version is in beta. Some features may change before release.

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.

Code
rust
App::builder()
.settings(settings)
.database("default", pool)
.model::<Post>()
.build()?;

Write the first migration

From your project root:

Code
bash
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_post

The 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.

Code
bash
cargo run -- makemigrations

No 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.

Code
bash
cargo run -- migrate # apply all pending
cargo run -- migrate --allow-drift # proceed past orphan tracking rows
cargo run -- migrate --fake <plugin>/<name> # mark one migration applied without running SQL
cargo run -- migrate --fake-initial # for each plugin, fake-apply 0001 when its tables already exist
FlagWhat it doesWhen to reach for it
--allow-driftLogs 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-initialFor 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):

Code
bash
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 pending

No flags. The footer line reports the pending count.

Info

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 native ALTER COLUMN ... SET NOT NULL / DROP NOT NULL).

  • Safe type changeAlterColumn. The engine keeps a whitelist of data-preserving casts:

    • every scalar → Text (stringify is lossless)
    • integer widening (SmallIntIntegerBigInt)
    • float widening (RealDouble)
    • BigIntForeignKey (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.

  • Unsafe type changeUnsafeAlter error. TextBigInt, narrowing integer casts, format-dependent transitions (TextDate, TextUuid) 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).

Info
The `umbral_migrations` tracking table is keyed by `(plugin, name)`. Two plugins can each ship their own `0001_initial.json` without colliding.

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.

Code
txt
# 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:

Code
txt
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 models

No 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.

Info
The second-pass warning is intentional: identical column shapes are a heuristic, not a guarantee. If two independent models happen to share the exact same set of columns, the autodetector will incorrectly emit a rename. Review the generated migration before applying it, and replace the `RenameTable` with `DropTable` + `CreateTable` if the intent is not a rename.

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.