Manual contentsDatabaseBrowse 113 chapters
Manual 19 min read

Migrations

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:

suprnova make:migration create_users_table

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 sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Users::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Users::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(Users::CreatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Users::UpdatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Users::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Users {
    Table,
    Id,
    CreatedAt,
    UpdatedAt,
}

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 sea_orm_migration::prelude::*;

mod m20240115_120000_create_users_table;
mod m20240115_130000_create_posts_table;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m20240115_120000_create_users_table::Migration),
            Box::new(m20240115_130000_create_posts_table::Migration),
        ]
    }
}

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 suprnova::Application;

#[suprnova::main]
async fn main() {
    Application::new()
        .config(my_app::config::register)
        .bootstrap(my_app::bootstrap::bootstrap)
        .routes(my_app::routes::register)
        .migrations::<my_app::migrations::Migrator>()
        .run()
        .await
}

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 where SeaORM stays visible. Two reasons.

First, the schema builder compiles to SeaORM's own statements (Table::create(), Table::alter(), Index, ForeignKey) and runs them on the migration's SchemaManager. It adds no migration engine, so SeaORM is one step away for everything the builder does not cover: a column type change, a check constraint, a raw statement. You write that step with sea_orm_migration::prelude::* in the same up(). Second, migration files are pure Rust - your CI compiler verifies them.

If you need a SeaORM type the framework has not re-exported, the escape hatch is use suprnova::sea_orm;.

The builder differs from Laravel's Blueprint in four ways:

  • timestamps() and soft_deletes() create string columns, not native date-time columns. A model stores a DateTime<Utc> field as RFC 3339 text by default, and the string column is the one type that round-trips on all three backends.
  • There is no table rebuild on SQLite. An operation SQLite cannot run in place returns an error that names the operation.
  • There is no column type change. Schema::table adds, renames and drops columns, but it does not alter the type of an existing column.
  • suprnova make:migration generates the SeaORM form. The schema builder is the alternative you write by hand.

See The schema builder.

Migration structure

Every migration has two methods:

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    // Apply the change
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { /* ... */ }

    // Reverse the change
    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { /* ... */ }
}

Both arms return Result<(), DbErr> - bubble errors with ? and the framework turns a failed migration into a non-zero exit so deploy pipelines abort.

The schema builder

suprnova::schema::Schema is a shorter way to write the common migration. A closure records columns, indexes and foreign keys on a Blueprint, and the builder turns the description into SQL. This is the posts migration as a complete file:

use sea_orm_migration::prelude::*;
use suprnova::schema::Schema;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        Schema::create(manager, "posts", |t| {
            t.id();
            t.string("title");
            t.text("body").nullable();
            t.string("slug").length(120).unique();
            t.boolean("published").default(false);
            t.foreign_id("author_id")
                .constrained("users")
                .on_delete(ForeignKeyAction::Cascade);
            t.index(&["published", "created_at"]);
            t.timestamps();
            t.soft_deletes();
        })
        .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        Schema::drop_if_exists(manager, "posts").await
    }
}

Every Schema function is async, takes the migration's manager, and returns Result<_, DbErr>. The builder opens no connection of its own, so each statement runs on the migration's connection and inside its transaction.

The layer lives at suprnova::schema::Schema. Import it by that path. suprnova::Schema at the crate root is SeaORM's Schema, a different type.

Entry points

Function Effect
Schema::create(manager, table, closure) Creates the table, then its indexes. Foreign keys are part of CREATE TABLE.
Schema::table(manager, table, closure) Alters an existing table.
Schema::drop(manager, table) Drops the table. It is an error if the table does not exist.
Schema::drop_if_exists(manager, table) Drops the table if it exists.
Schema::rename(manager, from, to) Renames a table.
Schema::has_table(manager, table) Returns Result<bool, DbErr>: whether the table exists.
Schema::has_column(manager, table, column) Returns Result<bool, DbErr>: whether the column exists.

Column methods

A column is NOT NULL unless you call .nullable(). The table lists the column each method creates. The SQLite column is the type name SQLite stores, and its affinity is what the database reports.

Method SQLite Postgres MySQL
id() integer, auto-increment, primary key bigserial, primary key bigint, auto-increment, primary key
foreign_id(name) integer bigint bigint
big_integer(name) integer bigint bigint
integer(name) integer integer int
small_integer(name) integer smallint smallint
boolean(name) boolean boolean tinyint(1)
string(name) varchar(255) varchar(255) varchar(255)
char(name, length) char(length) char(length) char(length)
text(name) text text text
float(name) float real float
double(name) double double precision double
decimal(name, precision, scale) decimal(precision, scale) numeric(precision, scale) decimal(precision, scale)
date(name) date_text date date
time(name) time_text time time
date_time(name) datetime_text timestamp datetime
timestamp_tz(name) timestamp_with_timezone_text timestamp with time zone timestamp
json(name) json_text jsonb json
uuid(name) char(36) uuid char(36)
ulid(name) char(26) char(26) char(26)
binary(name) blob bytea blob

id() is a BIGINT on Postgres and MySQL, and foreign_id has the same type. The types match because MySQL refuses a foreign key between columns of different types. A table has one id().

Modifiers

Each column method returns a builder. Chain the modifiers on it.

Modifier Effect
.nullable() Allows NULL.
.default(value) Sets the value the database stores when an insert leaves the column out. It takes a plain Rust value (7, "draft", false) or a SeaQuery Expr such as Expr::current_timestamp().
.unique() Adds a unique index over this column alone, named {table}_{column}_unique.
.length(n) Sets the length of a string column. On any other column type the migration fails with an error that names the column. Use char(name, length) for a fixed length.

Timestamps and soft deletes

t.timestamps() adds created_at and updated_at, both NOT NULL. t.soft_deletes() adds a nullable deleted_at. These are VARCHAR(255) columns on every backend, not native date-time columns.

The reason is the model. A #[suprnova::model] field of type DateTime<Utc> with no declared cast uses AsDateTime, which stores RFC 3339 text (see Eloquent Mutators). Postgres refuses a text parameter for a timestamp column. A string column round-trips on all three backends.

For a native column, declare it with t.timestamp_tz("published_at") and give the model field a cast of your own. The Cast trait sets the storage type, so its Storage must be a native date-time type. No shipped cast has one: AsDateTime and the other temporal casts store text, and AsTimestamp stores an integer.

Indexes and foreign keys

Schema::create(manager, "comments", |t| {
    t.id();
    t.foreign_id("post_id")
        .constrained("posts")
        .on_delete(ForeignKeyAction::Cascade)
        .on_update(ForeignKeyAction::Cascade);
    t.string("author");
    t.index(&["post_id", "author"]);
    t.unique(&["post_id", "author"]);
})
.await
  • t.index(&["a", "b"]) creates an index named {table}_{columns}_index.
  • t.unique(&["a", "b"]) creates a unique index named {table}_{columns}_unique.
  • The columns are joined with _ in the name.
  • Indexes are separate CREATE INDEX statements that run after the table.
  • foreign_id(name).constrained(table) creates a foreign key to the id column of table, named {table}_{column}_foreign.
  • .references(table, column) points the key at another column. On MySQL that column must be a BIGINT.
  • .on_delete(action) and .on_update(action) take a ForeignKeyAction. The database default (NO ACTION) applies when you do not call them.
  • A foreign_id with no constrained or references call is a plain BIGINT column.

The builder refuses a name longer than 63 bytes for an index or a foreign key, on every backend. It is the limit Postgres keeps, so a migration that runs on one backend runs on all three.

Altering a table

Schema::table accepts:

  • new columns of any type, with the same modifiers
  • rename_column(from, to) and drop_column(name)
  • index, unique and drop_index(name)
  • foreign_id(..).constrained(..) and drop_foreign(name)
Schema::table(manager, "posts", |t| {
    t.string("subtitle").nullable();
    t.rename_column("body", "content");
    t.index(&["subtitle"]);
})
.await

It runs the operations in the order you write them, each as its own statement. Changing the type of an existing column is not supported. rename_column, drop_column, drop_index and drop_foreign belong to Schema::table: Schema::create returns an error if its closure records one.

The builder checks the description for the mistakes it can see before it runs the first statement: a duplicate column, an index over a column the table does not declare, an empty table name. A statement the database refuses stops the call. On MySQL and SQLite the migrator runs a migration without a transaction, so the statements before the refused one stay applied. On Postgres the migration's transaction rolls them back.

Every refusal the builder makes is a DbErr::Migration whose text starts with schema: and names the table, the column and the operation.

SQLite limits

SQLite cannot run some alterations in place, and the builder does not rebuild the table. Schema::table on SQLite returns an error, before it runs any statement of the call, for:

  • Adding or dropping a foreign key.
  • Adding a primary key column.
  • Adding a NOT NULL column with no default.

The errors read:

schema: cannot add the foreign key `{name}` on the existing table `{table}`: SQLite cannot add or drop a foreign key on an existing table; create the key with the table in Schema::create, or write this step with SeaORM's SchemaManager
schema: cannot add the primary key column `{column}` to the existing table `{table}`: SQLite cannot add a PRIMARY KEY column; create the table with id() instead
schema: cannot add the NOT NULL column `{column}` to the existing table `{table}` without a default: SQLite refuses it; call .nullable() or .default(value)

Dropping a foreign key reads cannot drop the foreign key in the first text.

SQLite also refuses CURRENT_TIMESTAMP as the default of a column added to an existing table, so use a constant default there. It refuses to drop a column that an index, a unique constraint or a foreign key covers: record the drop_index earlier in the same closure.

Both styles in one Migrator

A Schema migration and a SeaORM migration implement the same MigrationTrait, so both sit in one Migrator. One up() can mix them: call Schema::create for the table, then a manager call of your own for the step the builder does not cover.

Schema operations

These are the SeaORM forms of the same operations. The schema builder is the shorter alternative for the common cases.

Creating tables

use sea_orm_migration::prelude::*;

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .create_table(
            Table::create()
                .table(Users::Table)
                .if_not_exists()
                .col(
                    ColumnDef::new(Users::Id)
                        .integer()
                        .not_null()
                        .auto_increment()
                        .primary_key(),
                )
                .col(ColumnDef::new(Users::Email).string().not_null().unique_key())
                .col(ColumnDef::new(Users::Name).string().not_null())
                .col(ColumnDef::new(Users::PasswordHash).string().not_null())
                .col(ColumnDef::new(Users::CreatedAt).timestamp().not_null())
                .col(ColumnDef::new(Users::UpdatedAt).timestamp().not_null())
                .to_owned(),
        )
        .await
}

// Define the table and column identifiers
#[derive(DeriveIden)]
enum Users {
    Table,
    Id,
    Email,
    Name,
    PasswordHash,
    CreatedAt,
    UpdatedAt,
}

Dropping tables

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .drop_table(Table::drop().table(Users::Table).to_owned())
        .await
}

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

ColumnDef::new(Column::Name)
    .string()
    .not_null()                                // NOT NULL constraint
    .null()                                    // Allows NULL (default)
    .default("value")                          // Default value
    .default(Expr::current_timestamp())        // 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 table uses BIGINT because a 4-byte counter is the kind of constraint you regret three years in.

Adding columns

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Users::Table)
                .add_column(
                    ColumnDef::new(Users::PhoneNumber)
                        .string()
                        .null()
                )
                .to_owned(),
        )
        .await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Users::Table)
                .drop_column(Users::PhoneNumber)
                .to_owned(),
        )
        .await
}

Modifying columns

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Users::Table)
                .modify_column(
                    ColumnDef::new(Users::Name)
                        .string_len(500)  // Change VARCHAR(255) to VARCHAR(500)
                        .not_null()
                )
                .to_owned(),
        )
        .await
}

Renaming columns

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Users::Table)
                .rename_column(Users::Name, Users::FullName)
                .to_owned(),
        )
        .await
}

Indexes

Creating indexes

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .create_index(
            Index::create()
                .name("idx_users_email")
                .table(Users::Table)
                .col(Users::Email)
                .unique()  // Optional: make it unique
                .to_owned(),
        )
        .await
}

Composite indexes

manager
    .create_index(
        Index::create()
            .name("idx_posts_user_created")
            .table(Posts::Table)
            .col(Posts::UserId)
            .col(Posts::CreatedAt)
            .to_owned(),
    )
    .await

Dropping indexes

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .drop_index(Index::drop().name("idx_users_email").to_owned())
        .await
}

Foreign keys

Adding foreign keys

async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .create_table(
            Table::create()
                .table(Posts::Table)
                .if_not_exists()
                .col(
                    ColumnDef::new(Posts::Id)
                        .integer()
                        .not_null()
                        .auto_increment()
                        .primary_key(),
                )
                .col(ColumnDef::new(Posts::UserId).integer().not_null())
                .col(ColumnDef::new(Posts::Title).string().not_null())
                .col(ColumnDef::new(Posts::Content).text().not_null())
                .foreign_key(
                    ForeignKey::create()
                        .name("fk_posts_user")
                        .from(Posts::Table, Posts::UserId)
                        .to(Users::Table, Users::Id)
                        .on_delete(ForeignKeyAction::Cascade)
                        .on_update(ForeignKeyAction::Cascade),
                )
                .to_owned(),
        )
        .await
}

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).
suprnova make:migration create_posts_table

# 2. Edit src/migrations/m{ts}_create_posts_table.rs to define your schema.

# 3. Apply the migration.
suprnova migrate

# 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).
suprnova db:sync

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

Your application's serve and web:run subcommands 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.

suprnova serve, the development command, runs the pending migrations once when it starts, then starts the watched backend as serve --no-migrate, so a save of a source file does not run them again. Pass --migrate always to suprnova serve to migrate on every restart of the backend. See suprnova serve.

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 suprnova::testing::TestDatabase;
use crate::migrations::Migrator;

#[tokio::test]
async fn users_table_is_created() {
    let db = TestDatabase::fresh::<Migrator>().await.unwrap();
    // `db` is dropped at the end of the test, clearing the container.
}

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 fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager.create_table(/* ... */).await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager.drop_table(/* ... */).await
}

Use descriptive names

# Good: Describes the change
suprnova make:migration add_email_verified_to_users
suprnova make:migration create_order_items_table
suprnova make:migration add_index_to_posts_slug

# Bad: Vague names
suprnova make:migration update_users
suprnova make:migration change_table

One change per migration

Keep migrations focused on a single change:

# Good: Separate migrations
suprnova make:migration create_categories_table
suprnova make:migration add_category_id_to_posts

# Avoid: Multiple unrelated changes in one migration

Test migrations both ways

Before committing, verify both directions work:

suprnova migrate           # Apply
suprnova migrate:rollback  # Rollback
suprnova migrate           # Apply again

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* and db: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