Introduction
migrant is database migration management for SQLite, PostgreSQL, and MySQL. It
is two things that share one engine (migrant_lib):
- A CLI (
migrant) that manages plainup.sql/down.sqlfiles under a project directory, tracking applied migrations in a__migrant_migrationstable. - A library (
migrant_lib) that embeds the same management in your own application. Migrations can be SQL files, SQL strings compiled into the binary (include_str!), or Rust functions.
Migrations are applied in definition order (file migrations order by a timestamp in the tag). Each migration is applied together with its bookkeeping row in a single transaction by default, and concurrent migration runs against a server database serialize behind an advisory lock.
This site is the reference for installing, using, and embedding migrant. Start
with Install and the Quickstart. The CLI
is the full command reference and Writing migrations covers the
file layout. For the behavior that matters most in production, see
Transactions and Concurrency and locking.
To embed migrant in your own program, see Using migrant_lib.
The normative behavior is the spec.
Install
The migrant CLI builds without any database features by default, so pick at
least one of postgres / sqlite / mysql. The update feature enables
migrant self update, and vendored-openssl statically links OpenSSL for
portable (musl) builds.
Binary releases
Pre-built binaries for Linux (gnu and musl), macOS (x86_64 and aarch64), and
Windows are attached to each GitHub release.
Release binaries are built with all three database backends and the update
feature, so they can self-update:
migrant self update
cargo
# with postgres
cargo install migrant --features postgres
# with bundled sqlite
cargo install migrant --features sqlite
# everything, including self-update
cargo install migrant --features 'postgres sqlite mysql update'
Note that a cargo install without the update feature cannot self-update, and
the postgres driver needs its dev library (libpq-dev). The sqlite feature
bundles SQLite.
Docker
An image with the binary installed is published as jaemk/migrant:latest:
docker run --rm jaemk/migrant:latest migrant --help
As a library
Add migrant_lib to your project and enable a backend feature. See
Using migrant_lib.
[dependencies]
migrant_lib = { version = "1.0.0-rc.1", features = ["postgres"] }
Next: the Quickstart.
Quickstart
A file-based SQLite project from scratch. Every command is run from inside the
project directory (the one holding Migrant.toml).
1. Initialize
migrant init --type sqlite
This writes a Migrant.toml and, when run interactively, runs setup to create
the __migrant_migrations tracking table. See Configuration
for the file format and server-database options.
2. Create a migration
migrant new create-users
This creates a timestamped directory under migrations/ with empty up.sql and
down.sql files:
migrations/
20260713094500_create-users/
up.sql
down.sql
Fill them in. Do not add your own begin/commit: migrant wraps each migration
in a transaction (see Transactions).
-- up.sql
create table users (id integer primary key, name text not null);
-- down.sql
drop table users;
3. Apply
migrant list # show applied vs available
migrant apply --all # apply every pending up migration
apply moves one migration at a time by default; --all runs every remaining
one. To revert, add --down:
migrant apply --down # revert the most recent migration
migrant apply --down --all # revert everything
4. Inspect
migrant list # status of each migration
migrant which-config # path of the active Migrant.toml
migrant shell # open a database repl (sqlite3/psql/mysqlsh)
migrant tui # interactive terminal UI
That is the whole loop: new, edit the SQL, apply. The full command reference
is in The CLI.
The CLI
migrant manages migrations that live under <project-dir>/migrations/, where
project-dir is the closest parent directory containing a Migrant.toml.
Applied migrations are tracked in a __migrant_migrations table. Commands are
run from anywhere inside the project; migrant searches upward for the config.
Project setup
migrant init [--type <sqlite|postgres|mysql>] [--location <dir>] [--default-from-env] [--no-confirm]- Create a
Migrant.toml. Run interactively (without--no-confirm) it also runssetup.--default-from-envseeds every value as anenv:VARreference instead of a literal (see Configuration). migrant setup- Verify database credentials and create the
__migrant_migrationstable if it is missing. migrant which-config- Print the path of the active
Migrant.toml. migrant connect-string- Print the connection string (server databases) or the database file path (SQLite).
Migrations
migrant new <tag>- Generate a timestamped
<stamp>_<tag>/directory with emptyup.sqlanddown.sql. Tags may contain[a-z0-9-]. migrant edit <tag> [--down]- Open the
up.sql(ordown.sqlwith--down) for a migration matching<tag>in$EDITOR. migrant list- List available migrations and mark those applied.
migrant status [--format <text|json>]- Report every managed migration with its applied/pending state and summary
counts.
--format text(the default) prints a summary line plus a[✓]/[ ]row per migration;--format jsonprints the same data as JSON ({ total, applied, pending, migrations: [{ tag, applied }] }) for scripting. migrant apply [--down] [--all] [--force[=<mode>]] [--fake] [--no-sync]- Apply the next migration.
--downreverts instead of applying.--allruns every remaining migration in the chosen direction.--forcecontinues past a failed migration: bare--force(or--force=accept-failures) records the failed migration as applied anyway, so it is not retried on later runs;--force=skip-failuresleaves it unrecorded and retries it on the next run.--fakerecords the migration as (un)applied without running its SQL.--no-syncdisables the cross-process advisory lock that is otherwise on by default for PostgreSQL/MySQL; use it when migrations are already serialized by an external mechanism. migrant redo [--all] [--force[=<mode>]] [--no-sync]- Shortcut for the latest
downthenup. Useful while iterating on a migration you are still writing.--no-syncdisables the advisory lock for both the down and up runs.
Inspect and connect
migrant shell- Open a database repl. Requires the matching client on your
PATH:sqlite3for SQLite,psqlfor PostgreSQL, and for MySQLmysqlshwhen installed, falling back to the classicmysqlclient. The password is passed out of band (PGPASSWORD/MYSQL_PWD), never on the command line. migrant tui- Interactive terminal UI for viewing and applying migrations. Keys:
j/k(or Down/Up) move the selection,uapplies the next migration anddreverts the last,aapplies all andDreverts all,rrefreshes from the database, andq(or Esc / Ctrl-C) quits.
Maintenance
migrant self update [--no-confirm] [--quiet]- Replace the running binary with the latest GitHub release. Only works when the
binary was built with the
updatefeature (release binaries are; a plaincargo installis not). migrant self bash-completions [install [--path <path>]]- Generate a bash completion script. Without
installit is written to stdout, so you can redirect it yourself. Withinstallit is written to a file (default/etc/bash_completion.d/migrant) and the success message goes to stderr.
Behavior worth knowing
- Each migration and its bookkeeping row are applied in one transaction by
default. See Transactions and the
-- migrant:no-transactiondirective for DDL that cannot run in a transaction. - Runs against PostgreSQL/MySQL take an advisory lock so concurrent
migrantprocesses serialize. See Concurrency and locking.
Writing migrations
A CLI migration is a directory holding an up.sql and a down.sql, named with a
timestamp and a tag:
migrations/
20260713094500_create-users/
up.sql
down.sql
20260714101500_add-users-email/
up.sql
down.sql
migrant new <tag> generates the directory and the two empty files. The
timestamp prefix defines application order: migrations apply oldest-first on the
way up, newest-first on the way down. Tags may contain [a-z0-9-].
up and down
up.sql moves the schema forward; down.sql reverses it. Keep them inverses so
apply --down cleanly undoes apply.
-- 20260714101500_add-users-email/up.sql
alter table users add column email text;
-- 20260714101500_add-users-email/down.sql
alter table users drop column email;
Multiple statements per file are fine. Do not wrap them in your own
begin/commit: migrant applies each migration inside a transaction already
(see Transactions).
Order and the tracking table
Applied migrations are recorded by tag in the __migrant_migrations table.
migrant list reads that table to mark which migrations are applied:
Current Migration Status:
-> [✓] 20260713094500_create-users
-> [ ] 20260714101500_add-users-email
apply runs the next unapplied migration in timestamp order; apply --all runs
the rest. apply --down reverts the most recently applied one.
Editing and iterating
migrant edit <tag>opensup.sqlin$EDITOR; add--downfordown.sql.migrant redore-runs the latest migration (down then up) so you can iterate on SQL you are still writing.
Non-transactional DDL
Some statements cannot run inside a transaction (for example PostgreSQL
CREATE INDEX CONCURRENTLY or ALTER TYPE ... ADD VALUE). Put a directive at
the top of that direction’s file to opt it out:
-- migrant:no-transaction
alter type mood add value 'excited';
See Transactions for the full rules.
Transactions
By default, migrant applies each migration’s SQL and its __migrant_migrations
bookkeeping row in a single transaction. The schema change and the record that it
was applied commit or roll back together, so a migration that fails partway
leaves neither partial schema nor a bookkeeping row. Do not add your own
begin/commit to migration SQL; migrant manages the transaction.
What is wrapped
Wrapping is resolved per direction (up vs down). The default is on. It covers
the SQL migrant runs itself:
- CLI file migrations (
up.sql/down.sql). - Library
FileMigrationandEmbeddedMigration.
Function migrations (FnMigration) run arbitrary Rust and may open their own
connections, so migrant never wraps them.
Backend behavior
The guarantee depends on how each backend treats DDL inside a transaction:
| Backend | DDL in a transaction | Result |
|---|---|---|
| SQLite | transactional | schema and DML roll back together |
| PostgreSQL | transactional (with exceptions below) | schema and DML roll back together |
| MySQL / MariaDB | implicit commit per DDL statement | only pure-DML migrations are atomic; DDL cannot roll back |
On MySQL a CREATE TABLE/ALTER TABLE commits the moment it runs, so wrapping
there makes only DML-only migrations atomic. This is a property of the database,
not a setting.
Opting out: statements that cannot run in a transaction
Some PostgreSQL statements refuse to run inside a transaction block, for example
CREATE INDEX CONCURRENTLY and ALTER TYPE ... ADD VALUE. Wrapping such a
migration would error. Opt the affected direction out.
From the SQL file (CLI and library)
Put the directive on a comment line in that direction’s SQL. This is the way to opt out from a CLI file migration, with no Rust code:
-- migrant:no-transaction
alter type mood add value 'excited';
Rules:
- Resolved per direction and per source: an
up.sql/down.sqlfor a file migration, the embedded string for an embedded migration. Put it only in the direction that needs it. - Matched case-insensitively as the first token of a
--comment, so a trailing note is allowed:-- migrant:no-transaction (enum add). - A directive in the SQL takes precedence over the builder flag below.
From Rust (library)
EmbeddedMigration and FileMigration expose no_transaction(), which opts
both directions out:
#![allow(unused)]
fn main() {
EmbeddedMigration::with_tag("add-index")
.up("create index concurrently idx_users_email on users (email);")
.down("drop index idx_users_email;")
.no_transaction()
.boxed();
}
For per-direction control, prefer the SQL directive. When both are present, the directive wins.
What opting out changes
Without a transaction, a failed migration is not rolled back: earlier statements in the file stay applied. migrant still does not record the migration as applied when it fails, so a re-run will attempt it again. Write opted-out migrations so a partial application is safe to retry.
Note that --force changes the recording rule: bare --force
(accept-failures) records a failed migration as applied anyway, while
--force=skip-failures keeps the not-recorded/retry behavior described above.
See the apply flags.
Interaction with locking
Transaction wrapping is independent of the migration advisory lock. The lock spans the whole run and each migration’s transaction nests inside it. See Concurrency and locking.
Concurrency and locking
When several processes run migrations against the same server database at once (a common case: multiple app instances applying migrations on boot), they must not race. migrant serializes them with a database advisory lock.
What happens
For a run against PostgreSQL or MySQL, migrant takes a session-level advisory lock that it holds for the whole run:
- PostgreSQL:
pg_advisory_lock. - MySQL:
GET_LOCK(blocks until the lock is available).
A second migrator blocks until the first finishes, then proceeds. The lock is released when the run ends, and automatically by the database if the connection (session) drops, so a crashed migrator cannot leave a stuck lock.
After taking the lock, migrant re-reads the applied migrations. A run that waited for a peer therefore observes the migrations that peer committed and does not re-apply them.
SQLite has no advisory lock and no cross-process migration concurrency (a single connection already serializes writers), so locking is a no-op there.
Interaction with --force
migrant apply --force continues past a failed migration. On a server database a
failed statement is recovered in place (a rollback) rather than by dropping the
connection, so the session, and the advisory lock it holds, survives the error. A
--force run keeps holding the lock as it continues, with no window for another
migrator to interleave.
The one case where the lock is unavoidably lost mid-run is a genuinely dead connection: if the session ends, the database has already released the lock. A synchronized run detects this and aborts with an error rather than continuing unserialized on a new, unlocked session. Re-run migrations to continue.
Library control
Runs are synchronized by default. The Migrator::synchronized(bool) builder
toggles it, for example when an outer mechanism already serializes migrations:
#![allow(unused)]
fn main() {
Migrator::with_config(&config)
.synchronized(false) // opt out of the advisory lock
.all(true)
.apply()?;
}
The migrant CLI exposes the same toggle as --no-sync on apply and redo,
for callers that serialize migrations externally; see The CLI.
See Using migrant_lib for the rest of the Migrator API.
Configuration
A project is configured by a Migrant.toml file. The active config is found by
searching upward from the current directory, so commands work from any
subdirectory. migrant which-config prints the path in use.
Migrant.toml
Keys:
database_type(required):sqlite,postgres, ormysql.migration_location: directory holding migration folders. Defaultmigrations. A relative path resolves against the config file’s directory.- SQLite:
database_path. A relative path resolves against the config file’s directory. - Server databases:
database_name,database_user,database_password,database_host,database_port. database_params: a table of extra connection parameters.ssl_cert_file(PostgreSQL): path to a custom SSL certificate.
SQLite
database_type = "sqlite"
database_path = "db/migrant.db"
migration_location = "migrations"
PostgreSQL
database_type = "postgres"
database_name = "myapp"
database_user = "myapp"
database_password = "secret"
database_host = "localhost"
database_port = 5432
migration_location = "migrations"
[database_params]
sslmode = "require"
MySQL uses the same server keys with database_type = "mysql". database_port
accepts a TOML integer or a string.
Environment variables
Any value written as env:VAR_NAME is resolved from the environment when the
config loads. Keep secrets out of the file:
database_password = "env:DB_PASSWORD"
migrant init --default-from-env seeds every value in this form.
The migrant CLI loads a .env file automatically (via dotenvy) before values
are resolved, so env: references can come from .env during local
development. The library does not load .env: Config::from_settings_file
resolves env: references from the process environment as-is, so load any
.env file yourself before creating the config.
Connection strings and SSL
migrant connect-string prints the resolved connection string (or the file path
for SQLite). Credentials and parameters are percent-encoded, so special
characters in passwords and params are safe.
For PostgreSQL, TLS is selected from the connection: sslmode=disable (or an
absent sslmode) connects without TLS; any other sslmode
(prefer/require/verify-ca/verify-full) connects with TLS using the system
trust roots. ssl_cert_file verifies the server against a specific root
certificate instead. See Database backends.
Database backends
migrant supports SQLite, PostgreSQL, and MySQL. Each is behind a cargo feature so you only build the drivers you need.
| Database | CLI feature | Library feature | Driver |
|---|---|---|---|
| SQLite | sqlite | sqlite | rusqlite |
| PostgreSQL | postgres | postgres | postgres |
| MySQL | mysql | mysql | mysql |
The library also has all to enable all three. No backend is enabled by
default; invoking an operation whose feature is disabled returns
Error::FeatureRequired rather than panicking. The pre-1.0 d-sqlite /
d-postgres / d-mysql / d-all names remain as deprecated aliases.
SQLite
File-backed or in-memory. The sqlite CLI feature bundles SQLite; the library’s
sqlite feature does not bundle it (enable rusqlite’s bundled feature in your
own project if you want that). DDL is transactional, so migrations roll back
cleanly on failure.
An in-memory database (:memory:) lives entirely in one connection. migrant
keeps that connection alive for the life of the Config and its clones, so
migrations and later queries see the same database. See
Using migrant_lib.
PostgreSQL
DDL is transactional except for a handful of statements that cannot run in a
transaction block (CREATE INDEX CONCURRENTLY, ALTER TYPE ... ADD VALUE,
VACUUM, and similar). Opt those migrations out with the
-- migrant:no-transaction directive; see Transactions.
TLS is chosen from the connection string’s sslmode:
- absent or
sslmode=disable: no TLS (the default). - any other value (
prefer/require/verify-ca/verify-full): TLS using the system trust roots. ssl_cert_filein the config: verify the server against that root certificate.
The postgres driver needs libpq’s dev package (libpq-dev) to build.
MySQL / MariaDB
DDL commits implicitly, one statement at a time. A transaction cannot roll back
DDL, so transaction wrapping only makes pure-DML migrations atomic. This is a
property of the database; the -- migrant:no-transaction directive and
no_transaction() do not change it. Plan MySQL DDL migrations so a partial
application is safe to retry.
Static builds
The CLI’s vendored-openssl feature statically links OpenSSL for portable
(musl) builds. Release binaries are built with all three database backends and
the update feature (the musl target also enables vendored-openssl).
Troubleshooting
__migrant_migrations table is missing
Run migrant setup (or Config::setup() in the library) before applying
migrations. It verifies credentials and creates the tracking table.
A PostgreSQL migration errors with “cannot run inside a transaction block”
migrant wraps each migration in a transaction by default. Statements like
CREATE INDEX CONCURRENTLY and ALTER TYPE ... ADD VALUE cannot run there. Put
the directive at the top of that direction’s SQL:
-- migrant:no-transaction
alter type mood add value 'excited';
See Transactions.
A MySQL migration was not rolled back on failure
MySQL commits DDL implicitly, so a failed DDL migration is not rolled back. Only pure-DML migrations are atomic on MySQL. Write DDL migrations so a partial application is safe to retry. See Database backends.
A second migrator seems to hang
Against PostgreSQL/MySQL, migrant takes an advisory lock for the run. A second process blocks until the first releases it. This is expected: it prevents concurrent runs from racing. See Concurrency and locking. If a process truly died holding the lock, the database releases it when that session ends.
migrant self update says it is unavailable
Self-update only works when the binary was built with the update feature.
Release binaries include it; cargo install migrant --features postgres (for
example) does not. Reinstall from a release
or add the update feature.
migrant shell cannot find the client
shell runs the database’s own client: sqlite3, psql, or for mysql
mysqlsh when installed and the classic mysql client otherwise. Install a
matching client and make sure it is on your PATH.
Wrong config is picked up
migrant searches upward from the current directory for Migrant.toml. Run
migrant which-config to see which file is active.
Using migrant_lib
migrant_lib embeds migration management in your own program. The CLI is a thin
wrapper around it, so anything the CLI does can be done from Rust. Enable a
backend feature (sqlite, postgres, mysql, or all):
[dependencies]
migrant_lib = { version = "1.0.0-rc.1", features = ["postgres"] }
The pieces
Settingsdescribes the database connection, built with a typed builder or loaded from aMigrant.toml.Configholds the settings, the set of migrations, and a live connection. It is cheap to clone; clones share the same connection.Migratorapplies migrations against aConfig.
A minimal run
#![allow(unused)]
fn main() {
use migrant_lib::{Config, Migrator, Settings, EmbeddedMigration};
fn run() -> Result<(), Box<dyn std::error::Error>> {
let settings = Settings::configure_sqlite()
.database_path("/abs/path/to/db.db")?
.build()?;
let mut config = Config::with_settings(settings);
config.setup()?; // create the __migrant_migrations table
config.use_migrations(&[
EmbeddedMigration::with_tag("create-users")
.up("create table users (id integer primary key, name text);")
.down("drop table users;")
.boxed(),
])?;
// Apply everything. The migrator re-reads applied state from the database
// itself, so no manual reload is needed before applying. `apply` returns a
// `Report` of the tags it applied; an empty report means nothing was pending.
let report = Migrator::with_config(&config)
.all(true)
.apply()?;
println!("applied {} migration(s)", report.len());
Ok(())
}
}
Config::reload() re-reads the applied set from the database and returns a
fresh Config. It is only needed when your code inspects applied state (for
example via migration_statuses) after applying; Migrator::apply refreshes
its own view.
Settings builders
Settings::configure_sqlite():database_path(...),memory()for an in-memory database,migration_location(...).Settings::configure_postgres():database_name/user/password/host/port,ssl_cert_file(...),database_params(...).Settings::configure_mysql(): the same name/user/password/host/port anddatabase_params.
Or load from a file: Config::from_settings_file("Migrant.toml").
Relative paths resolve against the settings file’s directory when there is one.
With Config::with_settings (no file on disk) there is nothing to resolve
against: a relative migration_location falls back to the current directory,
and a relative sqlite database_path is an error, so give an absolute path (or
:memory:).
Inspecting status
migration_statuses(&config) returns every managed migration with an applied
flag; pending_migrations(&config) returns just the un-applied tags, in the
order they would run. Both read the config’s current applied set, so reload()
first if you need it fresh.
The Migrator
#![allow(unused)]
fn main() {
Migrator::with_config(&config)
.direction(migrant_lib::Direction::Up) // or Down
.all(true) // every remaining migration, not just the next
.force(migrant_lib::ForceMode::Off) // or AcceptFailures / SkipFailures
.fake(false) // record without running SQL
.synchronized(true) // advisory lock for server databases (default)
.show_output(true)
.apply()?;
}
ForceMode controls failed-migration handling: Off (default) aborts the run,
AcceptFailures continues and records the failed migration as applied,
SkipFailures continues without recording it so the next run retries it.
synchronized controls the advisory lock; see
Concurrency and locking. Transaction wrapping is per migration;
see Migration types and Transactions.
In-memory SQLite
The path :memory: (via Settings::configure_sqlite().memory()) selects an
in-memory database. Its entire state lives in one connection, which the Config
keeps alive and shares with its clones, so migrations and later queries see the
same database. A function migration reaches it with
ConnConfig::sqlite_connection().
See the examples for complete programs.
Migration types
Config::use_migrations(&[...]) registers an explicit, ordered list of boxed
Migratable values. Three types are built in.
FileMigration
Runs up/down SQL loaded from files at runtime.
#![allow(unused)]
fn main() {
use migrant_lib::FileMigration;
fn run() -> Result<(), Box<dyn std::error::Error>> {
FileMigration::with_tag("create-users")
.up("migrations/create_users/up.sql")?
.down("migrations/create_users/down.sql")?
.boxed();
Ok(())
}
}
The files must exist at runtime; relative paths resolve from the working directory.
EmbeddedMigration
Runs up/down SQL from strings compiled into the binary, so no files are
needed at runtime. include_str! embeds a file’s contents.
#![allow(unused)]
fn main() {
use migrant_lib::EmbeddedMigration;
fn run() {
EmbeddedMigration::with_tag("create-places")
.up(include_str!("../migrations/create_places/up.sql"))
.down("drop table places;")
.boxed();
}
}
FnMigration
Runs arbitrary Rust with the signature
fn(ConnConfig) -> Result<(), Box<dyn std::error::Error>>. Use it for data
migrations or anything SQL alone cannot express.
#![allow(unused)]
fn main() {
use migrant_lib::{FnMigration, ConnConfig};
fn seed(conn: ConnConfig) -> Result<(), Box<dyn std::error::Error>> {
// open a connection from conn.connect_string()? / conn.sqlite_connection()?
Ok(())
}
fn run() {
FnMigration::with_tag("seed-users")
.up(seed)
.down(migrant_lib::migration::noop)
.boxed();
}
}
Transactions per migration
Migratable::use_transaction(direction) decides whether migrant wraps a
migration in a transaction for that direction. The default is true.
FileMigrationandEmbeddedMigrationare wrapped by default. Opt out withno_transaction()(both directions) or a-- migrant:no-transactiondirective in the SQL (per direction). A directive in the SQL takes precedence over the builder flag.FnMigrationruns arbitrary code and may open its own connections, so it is never wrapped.
See Transactions for the directive rules and backend behavior.
Tags and CLI compatibility
Tags must be unique and contain [a-z0-9-]. To interoperate with the migrant
CLI (whose file migrations are timestamp-prefixed), call
Config::use_cli_compatible_tags(true) before use_migrations/reload, which
requires tags of the form [0-9]{14}_[a-z0-9-]+.