Migrations
Database migrations are version-controlled changes to your schema. They record every CREATE TABLE, ALTER TABLE, and index addition in order, so any developer can reproduce the exact database state from scratch. SQLx provides a migration tool that integrates tightly with the Rust build process.
Setting Up sqlx-cli
Install the CLI tool:
cargo install sqlx-cli --features postgres
Set your database URL:
export DATABASE_URL="postgres://user:pass@localhost/mydb"
Or create a .env file in your project root:
DATABASE_URL=postgres://user:pass@localhost/mydb
Create the database if it does not exist:
sqlx database create
Creating Migrations
Generate a new migration:
sqlx migrate add create_users_table
This creates a file like migrations/20240115120000_create_users_table.sql. Write your SQL in it:
-- migrations/20240115120000_create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_username ON users (username);
Run the migration:
sqlx migrate run
Applied 20240115120000/migrate create_users_table (25.4ms)
Reversible Migrations
By default, sqlx migrate add creates a single .sql file that only goes up. For reversible migrations, use the --reversible flag:
sqlx migrate add --reversible add_tasks_table
This creates two files:
migrations/20240115130000_add_tasks_table.up.sql
migrations/20240115130000_add_tasks_table.down.sql
Write the up migration:
-- migrations/20240115130000_add_tasks_table.up.sql
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_user_id ON tasks (user_id);
Write the down migration:
-- migrations/20240115130000_add_tasks_table.down.sql
DROP TABLE IF EXISTS tasks;
Revert the last migration:
sqlx migrate revert
Running Migrations in Code
For production, run migrations on application startup:
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() {
let pool = PgPoolOptions::new()
.max_connections(20)
.connect("postgres://user:pass@localhost/mydb")
.await
.expect("Failed to connect to database");
sqlx::migrate!()
.run(&pool)
.await
.expect("Failed to run migrations");
// Application continues...
}
sqlx::migrate!() embeds the migrations directory into the binary at compile time. The application carries its own migrations and applies them on startup. This ensures the schema is always up to date when the application starts.
Migration Workflow in Development
A typical development workflow:
# 1. Create a migration
sqlx migrate add add_tags_to_tasks
# 2. Write the SQL
# Edit migrations/20240115140000_add_tags_to_tasks.sql
# 3. Run it
sqlx migrate run
# 4. If something is wrong, fix and re-run
# For reversible migrations:
sqlx migrate revert
# Fix the SQL
sqlx migrate run
# 5. Check migration status
sqlx migrate info
Applied 20240115120000 create_users_table (applied)
Applied 20240115130000 add_tasks_table (applied)
Pending 20240115140000 add_tags_to_tasks (pending)
Migration Workflow in CI
In CI, you need a database to run compile-time checks and tests. A typical approach:
# .github/workflows/ci.yml (relevant steps)
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
steps:
- name: Run migrations
run: sqlx migrate run
env:
DATABASE_URL: postgres://test_user:test_pass@localhost/test_db
- name: Build and test
run: cargo test
env:
DATABASE_URL: postgres://test_user:test_pass@localhost/test_db
For projects that want CI without a live database, SQLx supports offline mode:
# Generate query metadata locally (requires a running database)
cargo sqlx prepare
# This creates a .sqlx/ directory with cached query data
# Commit .sqlx/ to version control
# In CI, build without a database
SQLX_OFFLINE=true cargo build
Schema Migrations vs Data Migrations
Schema migrations change the structure: tables, columns, indexes, constraints. Data migrations change the contents: backfilling values, transforming existing rows.
Keep them separate when possible:
-- 20240115150000_add_status_column.sql
ALTER TABLE tasks ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';
-- 20240115150100_backfill_task_status.sql
UPDATE tasks SET status = 'completed' WHERE completed = TRUE;
UPDATE tasks SET status = 'pending' WHERE completed = FALSE;
-- 20240115150200_remove_completed_column.sql
ALTER TABLE tasks DROP COLUMN completed;
Three migrations, applied in order: add the new column, backfill the data, remove the old column. This approach lets you deploy incrementally — the application can work with both the old and new columns during the transition.
Schema Versioning
SQLx tracks applied migrations in a _sqlx_migrations table:
-- This table is created automatically
SELECT version, description, installed_on, success
FROM _sqlx_migrations
ORDER BY version;
version | description | installed_on | success
20240115120000 | create_users_table | 2024-01-15 12:00:05.123+00 | true
20240115130000 | add_tasks_table | 2024-01-15 13:00:02.456+00 | true
Never edit this table manually. Never rename or reorder migration files after they have been applied. Once a migration is in production, it is immutable — create a new migration to make changes.
Naming Conventions
Good migration names describe the change:
20240115120000_create_users_table.sql
20240115130000_add_tasks_table.sql
20240115140000_add_email_index_to_users.sql
20240115150000_add_status_to_tasks.sql
20240115160000_create_tags_table.sql
20240115170000_add_due_date_to_tasks.sql
Bad migration names:
20240115120000_update.sql
20240115130000_fix.sql
20240115140000_changes.sql
The name should tell you what changed without opening the file.
Common Pitfalls
- Editing applied migrations. Once a migration has been applied, it is locked. SQLx checks checksums. If you change a migration that has already run, future
migrate runcalls will fail with a checksum mismatch. - Not making migrations idempotent. Use
IF NOT EXISTSfor CREATE TABLE andIF EXISTSfor DROP TABLE. This prevents failures if a migration is accidentally applied twice. - Large data migrations in a single transaction. PostgreSQL locks the table during ALTER TABLE and large UPDATE statements. For big tables, batch the data migration or use concurrent index creation.
- Forgetting down migrations. If you always use irreversible migrations, you cannot roll back in development. Use
--reversiblefor migrations you might need to undo. - Storing the database URL in code. Always use environment variables or
.envfiles. Never commit credentials to version control.
Key Takeaways
- Use
sqlx-clito create, run, and revert migrations from the command line. sqlx::migrate!()embeds and runs migrations at application startup, keeping the schema in sync with the code.- Use
--reversiblefor migrations you might need to undo during development. - Separate schema migrations from data migrations for safer deployments.
- Use
cargo sqlx preparefor CI environments that lack a live database. - Never edit applied migrations. Create new ones to make changes.
- Name migrations descriptively so the history tells the story of your schema.