Migrations describe how your schema evolves - each file is a small Rust struct with up() and down() methods that the framework runs in timestamp order. Use them whenever you change tables, columns, indexes, or foreign keys; that change moves from your laptop to staging to production by running the same migrate command in each place.
Suprnova's migrations are SeaORM migrations underneath. The CLI generates them, the Migrator aggregates them, and Application::migrations::<Migrator>() plugs them into your app's boot. For the full per-command reference (flags, output samples, exit codes) see CLI Migrations Reference; this chapter covers what to put inside the files.
Creating migrations
Generate a new migration file:
The generator writes a timestamped file under src/migrations/ (creating the
directory the first time) and registers it in the Migrator:
src/migrations/
├── mod.rs ← the Migrator (CLI-managed)
└── m20240115_120000_create_users_table.rs
The filename is m{YYYYMMDD}_{HHMMSS}_<name>.rs; ordering is by filename, so
the timestamp prefix is what enforces a deterministic apply order.
What the generator emits
make:migration create_users_table produces this skeleton:
use *;
;
The generator infers the table name from the migration name
(create_X_table → X, add_Y_to_X → X, drop_X_table → X). Anything
else becomes the literal name.
The Migrator
src/migrations/mod.rs collects every migration into a single Migrator
that MigratorTrait walks. The CLI maintains this file when you
make:migration, so you rarely touch it by hand:
pub use *;
;
Wire the migrator into your app's main.rs so serve, migrate,
migrate:status, migrate:rollback, and migrate:fresh all see the same
list:
use Application;
async
The scaffolder writes this for you on suprnova new.
Why Suprnova diverges
Most of the framework deliberately hides SeaORM - you write #[suprnova::model]
and User::query().db_where(...), not Entity::find().filter(...). Migrations
are the one place we leave sea_orm_migration::prelude::* visible. Two reasons.
First, the schema-builder DSL is genuinely good and re-aliasing every name in
it (Table, ColumnDef, Index, ForeignKey, Expr, ForeignKeyAction,
DeriveIden, ...) would buy a longer import line and nothing else. Second,
migration files are pure Rust - your CI compiler verifies them - and that
catches more typos than any DSL re-aliasing would. We treat migrations like
schema-as-code, and the canonical SeaORM names are the schema vocabulary.
If you ever do need a SeaORM type the framework hasn't re-exported, the
escape hatch is use suprnova::sea_orm;. You almost never need it.
Migration structure
Every migration has two methods:
Both arms return Result<(), DbErr> - bubble errors with ? and the framework
turns a failed migration into a non-zero exit so deploy pipelines abort.
Schema operations
Creating tables
use *;
async
// Define the table and column identifiers
Dropping tables
async
Column types
| Method | Database Type | Notes |
|---|---|---|
integer() |
INTEGER | 32-bit integer |
big_integer() |
BIGINT | 64-bit integer |
small_integer() |
SMALLINT | 16-bit integer |
float() |
FLOAT | Floating point |
double() |
DOUBLE | Double precision |
decimal() |
DECIMAL | Fixed-point |
string() |
VARCHAR(255) | Variable length string |
string_len(n) |
VARCHAR(n) | Custom length string |
text() |
TEXT | Long text |
boolean() |
BOOLEAN | True/false |
timestamp() |
TIMESTAMP | Date and time |
date() |
DATE | Date only |
time() |
TIME | Time only |
blob() |
BLOB | Binary data |
json() |
JSON | JSON data |
uuid() |
UUID | UUID type |
Column modifiers
new
.string
.not_null // NOT NULL constraint
.null // Allows NULL (default)
.default // Default value
.default // Function default (e.g. NOW())
.unique_key // UNIQUE constraint
.primary_key // PRIMARY KEY
.auto_increment // AUTO_INCREMENT
For surrogate primary keys, prefer big_integer().auto_increment().primary_key()
on real tables - INTEGER (32-bit) is fine for tiny lookup tables but the
scaffolded users, sessions, and similar tables all use BIGINT because
a 4-byte counter is the kind of constraint you regret three years in.
Adding columns
async
async
Modifying columns
async
Renaming columns
async
Indexes
Creating indexes
async
Composite indexes
manager
.create_index
.await
Dropping indexes
async
Foreign keys
Adding foreign keys
async
Foreign key actions
| Action | Description |
|---|---|
Cascade |
Delete/update child rows automatically |
SetNull |
Set foreign key to NULL |
SetDefault |
Set foreign key to default value |
Restrict |
Prevent delete/update if referenced |
NoAction |
Similar to Restrict |
Migration workflow
A typical change goes through four steps:
# 1. Generate the file (creates src/migrations/m{ts}_create_posts_table.rs
# and updates src/migrations/mod.rs).
# 2. Edit src/migrations/m{ts}_create_posts_table.rs to define your schema.
# 3. Apply the migration.
# 4. Regenerate SeaORM entity files from the live schema so the models
# compile against the new shape. `db:sync` also runs any pending
# migrations first (use --skip-migrations to skip that step).
db:sync writes auto-generated entity glue to src/models/entities/<table>.rs
and a user-editable stub to src/models/<table>.rs. Re-running it updates the
entity files; your user stubs are left alone unless you pass
--regenerate-models (which overwrites them - keep custom methods elsewhere
or version-control before you run it).
Auto-migrate on serve
suprnova serve and suprnova web:run apply any pending migrations before
opening the HTTP socket. The default policy is fail-closed: if up()
errors, the process aborts non-zero before bind, so a broken migration can
never reach traffic.
Two escape hatches:
| Flag / env | Effect |
|---|---|
--no-migrate (on serve / web:run) |
Skip the auto-migrate step entirely. Useful when migrations run from a separate deploy step. |
SUPRNOVA_AUTO_MIGRATE_BEST_EFFORT=true |
Opt back into the legacy log-and-continue behaviour. The process keeps booting on a migration error. Not recommended in production. |
Background workers (queue:work, workflow:work, schedule:run) do not
auto-migrate - they assume schema is already in place when they boot, since
running migrations from N workers concurrently would race.
Running migrations in tests
TestDatabase::fresh::<Migrator>() spins up an isolated in-memory SQLite
database, runs every migration, and binds the connection into the test
container so DB::connection() and #[inject] resolve to it:
use TestDatabase;
use crateMigrator;
async
See Database Tests for the full pattern (factories, parallel safety, picking a real driver instead of in-memory SQLite).
Best practices
Always write down migrations
Always implement down() to allow rollbacks:
// Good: Reversible migration
async
async
Use descriptive names
# Good: Describes the change
# Bad: Vague names
One change per migration
Keep migrations focused on a single change:
# Good: Separate migrations
# Avoid: Multiple unrelated changes in one migration
Test migrations both ways
Before committing, verify both directions work:
CLI commands at a glance
| Command | Description |
|---|---|
suprnova make:migration <name> |
Create a new migration |
suprnova migrate |
Run all pending migrations |
suprnova migrate:status |
Show migration status |
suprnova migrate:rollback |
Rollback the last migration |
suprnova migrate:rollback --step 3 |
Rollback the last 3 migrations |
suprnova migrate:fresh |
Drop all tables and re-run every migration |
suprnova db:sync |
Run migrations and regenerate entity files |
suprnova db:sync --skip-migrations |
Regenerate entity files without applying migrations |
suprnova db:sync --regenerate-models |
Also overwrite user-editable model stubs |
See CLI Migrations Reference for the full per-command reference (flags, output samples, exit codes).
Next
- CLI Migrations Reference - flag-by-flag reference for
migrate*anddb:sync - Database - connection configuration, transactions, read/write split
- Eloquent - the model layer your migrations feed
- Seeding - populating tables once their schema exists
- Database Tests -
TestDatabase::fresh::<Migrator>()and parallel-safe patterns
