Umbral
Back to the blog
Tutorial Jul 9, 2026 9 min read

Managed migrations: the loop that is the product

Autodetection diffs your models against the last snapshot and emits ordered, reversible operations. inspectdb ports an existing database in. Here's how the declare → migrate → change → migrate loop actually works.

Migrations are where a lot of Rust web projects quietly fall back to hand-written SQL. Umbral treats the managed loop as the product, not an add-on.

The loop

  1. Declare or change a model. An autodetected migration is generated.
  2. migrate applies all pending migrations to the database.
  3. Update or delete a model. The diff produces the right ALTER/DROP.
# 1. you changed a model — autodetect and write the migration
cargo run -- makemigrations

# 2. review the generated file (it's plain, readable JSON), then apply
cargo run -- migrate

Autodetection

The autodetector diffs your current models against the last migration snapshot and emits ordered, reversible operations: create/alter/drop table, add/alter/drop column. The common cases — a new model, a dropped model, an added or removed field — work on day one. The genuinely hard cases (rename vs. drop+add disambiguation, data-preserving alters) get surfaced rather than guessed.

Existing rows are the test, not an obstacle

A rule we hold hard: never wipe the database to bypass a migration. If a UNIQUE addition trips a duplicate, or a new NOT NULL column needs a backfill default, that failure is the bug you want to find. Deleting the database to "get a clean run" just hides it until production.

umbral makemigrations: rename detected (column-shape match): `body` → `content`

Porting an existing database

Already have a schema? inspectdb introspects it and generates models that feed straight back into the same managed loop:

cargo run -- inspectdb > src/models.rs

From there you're in the normal declare → migrate cycle, with the full audit trail of how each column got its shape. Migration history is the schema's record — we never delete entries to "regenerate cleanly," because that makes older deploys un-migratable.

Cross-plugin foreign keys

Each plugin owns its own migrations. migrate walks every registered plugin, collects their migrations, orders them by a dependency graph — cross-plugin foreign keys included — and runs only those not yet recorded in the tracking table. The built-in auth, sessions, and tasks tables are created this exact way. Nothing is special-cased.