In a Node project you reach for a migration tool to evolve your schema over time — knex migrate, TypeORM’s migration:generate/migration:run, or Prisma Migrate. Rust has the same idea, expressed two ways: SQLx ships a sqlx migrate CLI plus a sqlx::migrate! macro that embeds your SQL into the binary, and Diesel ships a diesel migration CLI plus a diesel_migrations::embed_migrations! macro. This page covers writing reversible up/down scripts, applying and reverting them, and the pattern most servers want: running pending migrations automatically at startup.
A migration is a small, ordered, versioned change to your database schema — “create the users table”, “add a bio column” — recorded so every environment converges on the same schema. Both SQLx and Diesel store the list of already-applied migrations in a bookkeeping table (_sqlx_migrations / __diesel_schema_migrations) so re-running is a safe no-op. The big difference from the TypeScript tools you know: Rust migration runners can embed every migration file into the compiled binary, so the deployed executable carries its own schema history and needs no migration files alongside it on the server.
Note: Every command and Rust snippet here was run with cargo 1.96.0 (current stable; 2024 edition, which cargo new selects automatically). The CLI examples use SQLite so they reproduce with no database server to install. The current SQLx release is 0.9 (cargo add sqlx resolves it on Rust ≥ 1.94; on older toolchains it falls back to 0.8.6), and the migrate!/sqlx migrate APIs shown are identical across 0.8 and 0.9. Other crate versions at the time of writing: sqlx-cli0.9, Diesel 2.3.9, diesel_migrations2.3.2.
Here is a typical Knex migration workflow. You generate a timestamped file, fill in up and down, and run the migrator. The migration list lives in a knex_migrations table.
In Rust the up/down logic is plain SQL in .sql files, and the runner can embed those files into the binary. Here is the SQLx version. First the CLI to scaffold and run, then the code that runs migrations at startup.
Terminal window
1
# Install the CLI once (built with only the drivers you need; here SQLite + rustls).
sqlx migrate add -r <name> creates the pair of files. The -r (--reversible) flag is what gives you a down.sql; without it you get a single <timestamp>_<name>.sql with no rollback. The filename prefix is a version: by default a UTC timestamp, but SQLx switches to sequential numbering (0001, 0002, …) if it detects you started that way. Within a directory you must commit to one style — mixing reversible and non-reversible migrations is rejected.
The bookkeeping table. The first time you run migrations, SQLx creates _sqlx_migrations and records each applied version, its description, success flag, execution time, and a checksum of the SQL. After sqlx migrate run it contains:
1
20260601115557|create users|1
2
20260601120000|add posts|1
That checksum is load-bearing: it is how the runner knows a migration was already applied and detects if you edited an already-applied file (see Pitfalls).
sqlx::migrate!() is a procedural macro. At compile time it walks the migrations/ directory (next to Cargo.toml), reads every .sql file, and bakes their contents into a static Migrator. This is the opposite of Knex reading files at runtime: once compiled, your binary is self-contained. Unlike the query! macro, migrate!() does not need a live database at compile time — it only reads files — so it works without DATABASE_URL set during the build.
MIGRATOR.run(&pool) opens a transaction per migration, applies each pending one in version order, records it in _sqlx_migrations, and skips anything already recorded. Because it consults that table, calling it on every server start is the idiomatic pattern: the first boot migrates, every subsequent boot is a fast no-op.
Tip: The migrate! macro reads files at build time, but Cargo does not automatically rebuild when only an .sql file changes. Run sqlx migrate build-script once to generate a tiny build.rs that fixes this:
1
// build.rs — generated by `sqlx migrate build-script`
2
fnmain(){
3
// trigger recompilation when a new migration is added
Diesel’s model is similar but its CLI scaffolds a directory per migration containing up.sql and down.sql, and its runner lives in the separate diesel_migrations crate.
Terminal window
1
# The Diesel CLI, built for SQLite (or postgres / mysql).
The run_pending_migrations, revert_last_migration, and has_pending_migration methods come from the MigrationHarness trait — you must bring it into scope with use diesel_migrations::MigrationHarness; or the methods will not resolve. Note its error type is Box<dyn std::error::Error + Send + Sync>, which is why main returns exactly that (using a plain Box<dyn Error> fails to compile because the boxed error is not Sized). The returned versions are the migration timestamps with the separators stripped.
The headline conceptual difference: embedding. With sqlx::migrate!() / embed_migrations!(), the migration SQL becomes part of the compiled artifact. You deploy a single binary; there is no “did the migrations/ folder get copied to the server?” failure mode. That is impossible in the Node tools, which always read migration files from disk at runtime.
A second difference is who writes the SQL. Knex and Prisma generate DDL from a builder/schema; Diesel and SQLx have you write the DDL yourself. You trade some convenience for total control over indexes, constraints, and database-specific features — and the SQL is the same SQL you would run by hand.
Note: Diesel uniquely regenerates src/schema.rs when you run a migration, keeping its compile-time table! definitions in sync with the database. SQLx has no such file because it checks queries against a live database (or a cached .sqlx/ for offline mode) rather than a Rust-side schema. See Diesel intro for the schema.rs story.
This is the single most common migration mistake, in any language. Once a migration is recorded in the bookkeeping table, its checksum is fixed. Change the .sql file afterward and the next run refuses to proceed. The real error from MIGRATOR.run (and identically from sqlx migrate run) is:
1
Error: migration 20260601115557 was previously applied but has been modified
The fix is never to edit an applied migration: add a new migration that alters the schema forward. (During local development before sharing, you may instead sqlx migrate revert, edit, and re-run — but never on a shared or production database.)
cargo install sqlx-cli defaults to all native-TLS drivers; if you build it --no-default-features you must list the drivers you need. A CLI built without the sqlite feature fails the moment it touches a sqlite:// URL:
1
error: error with configuration: no driver found for URL scheme "sqlite"
Reinstall with the right features: cargo install sqlx-cli --no-default-features --features sqlite,rustls (add postgres and/or mysql as needed).
run_pending_migrations and friends are trait methods. Without use diesel_migrations::MigrationHarness; the compiler reports the method does not exist on the connection — the classic Rust “trait not in scope” symptom familiar from the Read/Write traits. Bring the trait into scope.
Returning the wrong error type from main with Diesel
Diesel’s migration methods return Box<dyn Error + Send + Sync>. A fn main() -> Result<(), Box<dyn std::error::Error>> will not compile against ? here, because Box<dyn Error + Send + Sync> does not coerce into the non-Send box through From cleanly (the error is the trait Sized is not implemented for dyn std::error::Error + Send + Sync). Match the type: Result<(), Box<dyn std::error::Error + Send + Sync>>.
Run migrations at startup for app-managed schemas. Calling MIGRATOR.run(&pool).await? (SQLx) or conn.run_pending_migrations(MIGRATIONS)? (Diesel) early in main makes every deploy self-healing: the first instance migrates, the rest see nothing pending. For multi-instance deploys, guard against concurrent runners (a Postgres advisory lock, or a one-off migration job in your pipeline) so two pods do not race on the same migration.
Embed, do not ship loose files. Prefer sqlx::migrate!() / embed_migrations!() over reading a runtime directory, so the binary is self-contained. Add the build.rsrerun-if-changed=migrations line so adding a migration triggers a rebuild.
Never edit an applied migration. Roll forward with a new migration. Treat applied migrations as immutable history.
Always write a real down.sql. Use diesel migration redo (or sqlx migrate revert then run) locally to prove the rollback actually restores the previous schema before you commit.
One logical change per migration, with a descriptive name (add_email_index, not update2). Small migrations are easier to review and to revert.
Keep DDL idempotent where the database allows it (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS) so a partially-applied migration is recoverable.
Commit the migration files (and SQLx’s .sqlx/ offline cache, if you use it) to version control. Build them into CI so your compile-time-checked queries match the migrated schema. See SQLx intro for offline mode.
A production web service that connects a pooled database, runs embedded migrations once at startup, then serves requests. This is the shape most Rust API servers use; the migration step is the first thing main does after building the pool. The example below is compile-verified end to end (it applies two real migrations and reads back a row).
// First boot migrates; every later boot finds nothing pending and returns fast.
14
MIGRATOR.run(&pool).await?;
15
Ok(pool)
16
}
17
18
#[tokio::main]
19
asyncfnmain()->anyhow::Result<()>{
20
leturl=std::env::var("DATABASE_URL")?;
21
letpool=init_db(&url).await?;
22
println!("schema ready");
23
24
// Pretend this is a request handler using the now-migrated schema.
25
sqlx::query("INSERT OR IGNORE INTO users (name, email) VALUES (?, ?)")
26
.bind("Grace")
27
.bind("grace@example.com")
28
.execute(&pool)
29
.await?;
30
letcount:i64=sqlx::query_scalar("SELECT COUNT(*) FROM users")
31
.fetch_one(&pool)
32
.await?;
33
println!("users table has {count} row(s)");
34
Ok(())
35
}
Output on a database that starts empty:
1
schema ready
2
users table has 1 row(s)
Tip: Wire this into a real HTTP server by handing the pool to your router’s shared state. See Connection Pooling for sizing the pool and Section 16: Web APIs for serving requests on top of it.
Objective: Scaffold, fill in, apply, and revert a migration using sqlx migrate.
Instructions: In an empty directory, set DATABASE_URL=sqlite://app.db, run sqlx database create, then sqlx migrate add -r create_products. Edit the generated up/down files so the up creates a products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price_cents INTEGER NOT NULL) table and the down drops it. Apply with sqlx migrate run, confirm with sqlx migrate info, then sqlx migrate revert and confirm the table is gone.
Objective: Embed migrations with sqlx::migrate! and apply them on boot, then prove re-running is a no-op.
Instructions: Using the migrations/ directory from Exercise 1, add sqlx with the migrate and macros features. Write a #[tokio::main] program that builds a SqlitePool, calls MIGRATOR.run(&pool), and prints how many products exist. Run it twice and confirm the second run does not error (migrations already applied).
MIGRATOR.run(&pool).await?;// first run applies; later runs are no-ops
12
letcount:i64=sqlx::query_scalar("SELECT COUNT(*) FROM products")
13
.fetch_one(&pool)
14
.await?;
15
println!("products in catalog: {count}");
16
Ok(())
17
}
Run DATABASE_URL=sqlite://app.db cargo run twice. Both runs print products in catalog: 0 (or whatever you inserted) with no migration error on the second run, because _sqlx_migrations already records the applied version.
Tip: Generate build.rs with sqlx migrate build-script so adding a future migration forces a rebuild that re-reads the directory.
Exercise 3: Embedded migrations and rollback with Diesel
Objective: Use diesel_migrations::embed_migrations!, apply at startup, then revert the latest migration programmatically.
Instructions: Create a directory migrations/2026-06-01-000001_create_widgets/ with up.sql creating a widgets (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL) table and down.sql dropping it. Add diesel (feature sqlite) and diesel_migrations. Write a synchronous main that opens an in-memory SQLite connection, runs all pending migrations, prints how many were applied, then calls revert_last_migration and prints the version it reverted. Remember the MigrationHarness import and the Send + Sync error type.