Skip to main content
  1. Languages/
  2. Rust Guides/

Bulletproof Database Migrations in Rust: From SQLx to SeaORM

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

It is 2025. If you are still manually SSH-ing into your production server to paste SQL commands into a terminal, we need to talk.

In the world of systems programming and high-performance web backends, data integrity is paramount. While Rust guarantees memory safety, it cannot inherently guarantee schema safety. That is where database migrations come in.

As your Rust application grows, your database schema will drift. You will need to add columns, split tables, and index foreign keys. Doing this manually is a recipe for disaster. You need a reproducible, version-controlled system to evolve your database state alongside your code.

In this guide, we will explore the two dominant strategies for handling migrations in the Rust ecosystem:

  1. The SQL-Native Approach using SQLx.
  2. The Programmatic Approach using SeaORM.

We will build a production-ready workflow that ensures your database schema is always in sync with your Rust binary.


Prerequisites and Environment Setup
#

Before we dive into the code, ensure your development environment is ready. We assume you are working on a Linux, macOS, or WSL2 environment.

1. Rust Toolchain
#

Ensure you are running a recent version of Rust (1.80+ recommended).

rustc --version

2. Database (PostgreSQL)
#

We will use PostgreSQL for this tutorial, as it is the gold standard for Rust backends. The easiest way to run it is via Docker:

# Spin up a Postgres container
docker run --name rust_migrations -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:16-alpine

3. Project Initialization
#

Let’s create a new project to test our migration strategies.

cargo new rust_migrations_demo
cd rust_migrations_demo

We will need a Cargo.toml that supports async runtime and our database tools.

File: Cargo.toml

[package]
name = "rust_migrations_demo"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15" # For managing .env files safely
anyhow = "1.0"   # For ergonomic error handling

# Option A: SQLx
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "migrate", "uuid"] }

# Option B: SeaORM (We will discuss this in the second section)
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = "1.1"

Create a .env file in your project root to store your connection string:

File: .env

DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/rust_migrations

Strategy A: The “Pure SQL” Approach with SQLx
#

SQLx is arguably the most beloved database tool in the Rust community. It is not an ORM; it’s an async, pure Rust SQL toolkit that provides compile-time checked queries. Its migration system is simple, file-based, and highly effective.

1. Installing the CLI
#

To manage migrations, you need the CLI tool.

# Install the SQLx CLI (ensure you don't install the sqlite/mysql features if not needed to save compile time)
cargo install sqlx-cli --no-default-features --features native-tls,postgres

2. Creating the Database
#

Using the CLI, create the database defined in your .env file.

sqlx database create

3. Creating Your First Migration
#

Migrations in SQLx are just SQL files with a timestamp prefix. Let’s create a users table.

sqlx migrate add create_users_table

This creates a new folder migrations/ with a file looking like 20260101120000_create_users_table.sql. Open it and add your DDL (Data Definition Language).

File: migrations/20260101120000_create_users_table.sql

-- Add migration script here
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Best Practice: Create an index for performance
CREATE INDEX idx_users_email ON users(email);

4. Applying Migrations (CLI)
#

To apply this to your running Postgres instance:

sqlx migrate run

SQLx tracks which migrations have been applied in a table called _sqlx_migrations.

5. Running Migrations Programmatically (Embedded)
#

In production, you often want your binary to run migrations automatically when it starts up, rather than relying on an external CLI tool. This is essential for containerized deployments (like Kubernetes).

File: src/main.rs

use sqlx::postgres::PgPoolOptions;
use std::env;
use dotenvy::dotenv;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv().ok();
    
    let database_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    // 1. Create a connection pool
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await?;

    println!("✅ Connection to database established.");

    // 2. Run Migrations
    // This embeds the SQL files into the binary at compile time!
    println!("⏳ Running migrations...");
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await?;

    println!("✅ Migrations applied successfully!");

    // Proceed with application logic...
    
    Ok(())
}

When you compile this with cargo build --release, the SQL files inside ./migrations are baked into the executable. You can ship a single binary that knows how to update its own database.


Strategy B: The Programmatic Approach with SeaORM
#

If you prefer a true ORM experience or need to write migrations in Rust (which allows for dynamic logic), SeaORM is the way to go. SeaORM separates the migration logic into a dedicated crate (library) within your workspace.

1. Setting up the Migration Crate
#

First, install the sea-orm-cli:

cargo install sea-orm-cli

Initialize the migration subsystem:

sea-orm-cli migrate init

This creates a migration folder. This is actually a separate Rust crate. You need to add it to your workspace.

File: Cargo.toml (Update the root config)

[workspace]
members = [".", "migration"]

2. Writing a Rust Migration
#

Go to migration/src/m20260101_000001_create_posts_table.rs (the file name will vary). SeaORM generates boilerplate for you. We define the schema using Rust structures.

File: migration/src/m20220101_000001_create_table.rs (Example content)

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    // The "Up" migration: Create table
    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::Title).string().not_null())
                    .col(ColumnDef::new(Posts::Text).string().not_null())
                    .to_owned(),
            )
            .await
    }

    // The "Down" migration: Drop table (for rollbacks)
    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Posts::Table).to_owned())
            .await
    }
}

#[derive(Iden)]
enum Posts {
    Table,
    Id,
    Title,
    Text,
}

3. Applying SeaORM Migrations
#

You can run this via CLI:

sea-orm-cli migrate up

Or, integrate it into your main application similarly to SQLx.

File: src/main.rs (SeaORM integration)

// Assuming you have imported the migration crate
// use migration::{Migrator, MigratorTrait}; 
// use sea_orm::Database;

/* 
   Note: To run this code, you must add `migration = { path = "./migration" }` 
   to your root Cargo.toml dependencies.
*/

// async fn main() -> Result<(), Box<dyn std::error::Error>> {
//     let db_url = "postgres://postgres:mysecretpassword@localhost:5432/rust_migrations";
//     let connection = Database::connect(db_url).await?;
    
//     Migrator::up(&connection, None).await?;
    
//     Ok(())
// }

Comparison: SQLx vs. SeaORM vs. Refinery
#

Choosing the right tool depends on your team’s background and project requirements.

Feature SQLx SeaORM Refinery
Migration Language Raw SQL (.sql) Rust Code Rust or SQL
Type Safety High (Compile-time query checking) High (Rust enums/structs) Moderate
Flexibility Maximum (Any valid SQL) Constrained by ORM API High
Performance Native speed Slight overhead Native speed
Complexity Low Medium (Requires separate crate) Low
Ideal For SQL Purists, High Performance OOP Developers, Complex Logic Simple Embeddable Needs

The CI/CD Pipeline: Best Practices
#

Migrations aren’t just about code; they are about operations. Running migrations in production requires a strict pipeline to prevent data loss.

Here is a visual representation of a robust migration workflow suitable for modern Rust deployments.

flowchart TD A[Developer Local] -->|Write Migration| B(Commit Code & SQL) B --> C{CI Pipeline} C -->|Run Tests| D[Unit/Integration Tests] C -->|Lint SQL| E[SQL Lint / Dry Run] D --> F[Build Docker Image] E --> F F --> G{Deployment} G -->|Phase 1| H[Database Migration Job] H -->|Success?| I[Deploy New App Version] H -->|Fail?| J[Alert & Abort] I --> K[Production Live] style A fill:#e1f5fe,stroke:#01579b,color:#000 style H fill:#fff9c4,stroke:#fbc02d,color:#000 style K fill:#e8f5e9,stroke:#2e7d32,color:#000

Critical Production Advice
#

  1. Transactional Migrations: PostgreSQL supports DDL (Data Definition Language) inside transactions. SQLx does this by default. If a migration fails halfway through (e.g., creating a table succeeds but creating an index fails), the entire operation rolls back. Never disable this.

  2. The “Expand and Contract” Pattern: How do you handle migrations with zero downtime? You cannot simply rename a column, because the old version of your app (still running) will query the old column name, while the database has the new one.

    • Step 1 (Expand): Add the new column, make it nullable (or with default). Deploy App v2 that writes to both columns but reads from the old one.
    • Step 2 (Migrate Data): Backfill the new column data from the old one.
    • Step 3 (Switch): Deploy App v3 that reads/writes only the new column.
    • Step 4 (Contract): Remove the old column in a future migration.
  3. Squashing Migrations: After a year of development, you might have 500 migration files. Running them all on a fresh CI instance takes forever. Periodically “squash” older migrations into a single init.sql schema snapshot.

  4. Avoid down Migrations in Production: While we write down (rollback) logic, in reality, automatic rollbacks in production are dangerous. Data written to a new column is lost if you rollback. It is often safer to “roll forward” by pushing a new migration that fixes the issue.


Conclusion
#

Database migrations in Rust have matured significantly. Whether you choose the raw power and simplicity of SQLx or the structured, programmatic approach of SeaORM, the ecosystem provides the tools necessary to sleep soundly at night.

For 90% of use cases, SQLx is the sweet spot. It keeps you close to the database, leverages your SQL knowledge, and integrates seamlessly with Rust’s async runtime. However, if your team struggles with SQL or requires complex dynamic schema generation, SeaORM is a fantastic, type-safe alternative.

Key Takeaways:

  • Embed migrations in your binary for simplified deployment.
  • Treat SQL files as first-class code (version control, linting).
  • Design migrations to be non-breaking for zero-downtime updates.

Further Reading
#

Now, go clean up those database schemas!