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:
- The SQL-Native Approach using
SQLx. - 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 --version2. 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-alpine3. Project Initialization #
Let’s create a new project to test our migration strategies.
cargo new rust_migrations_demo
cd rust_migrations_demoWe 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_migrationsStrategy 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,postgres2. Creating the Database #
Using the CLI, create the database defined in your .env file.
sqlx database create3. 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_tableThis 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 runSQLx 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-cliInitialize the migration subsystem:
sea-orm-cli migrate initThis 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 upOr, 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.
Critical Production Advice #
-
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.
-
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.
-
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.sqlschema snapshot. -
Avoid
downMigrations in Production: While we writedown(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 #
- SQLx GitHub Repository
- SeaORM Migration Guide
- Refactoring Databases (Book) - A classic on evolutionary database design.
Now, go clean up those database schemas!