Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 plain up.sql/down.sql files under a project directory, tracking applied migrations in a __migrant_migrations table.
  • 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 runs setup. --default-from-env seeds every value as an env:VAR reference instead of a literal (see Configuration).
migrant setup
Verify database credentials and create the __migrant_migrations table 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 empty up.sql and down.sql. Tags may contain [a-z0-9-].
migrant edit <tag> [--down]
Open the up.sql (or down.sql with --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 json prints 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. --down reverts instead of applying. --all runs every remaining migration in the chosen direction. --force continues 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-failures leaves it unrecorded and retries it on the next run. --fake records the migration as (un)applied without running its SQL. --no-sync disables 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 down then up. Useful while iterating on a migration you are still writing. --no-sync disables 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: sqlite3 for SQLite, psql for PostgreSQL, and for MySQL mysqlsh when installed, falling back to the classic mysql client. 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, u applies the next migration and d reverts the last, a applies all and D reverts all, r refreshes from the database, and q (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 update feature (release binaries are; a plain cargo install is not).
migrant self bash-completions [install [--path <path>]]
Generate a bash completion script. Without install it is written to stdout, so you can redirect it yourself. With install it 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-transaction directive for DDL that cannot run in a transaction.
  • Runs against PostgreSQL/MySQL take an advisory lock so concurrent migrant processes 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> opens up.sql in $EDITOR; add --down for down.sql.
  • migrant redo re-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 FileMigration and EmbeddedMigration.

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:

BackendDDL in a transactionResult
SQLitetransactionalschema and DML roll back together
PostgreSQLtransactional (with exceptions below)schema and DML roll back together
MySQL / MariaDBimplicit commit per DDL statementonly 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.sql for 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, or mysql.
  • migration_location: directory holding migration folders. Default migrations. 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.

DatabaseCLI featureLibrary featureDriver
SQLitesqlitesqliterusqlite
PostgreSQLpostgrespostgrespostgres
MySQLmysqlmysqlmysql

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_file in 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

  • Settings describes the database connection, built with a typed builder or loaded from a Migrant.toml.
  • Config holds the settings, the set of migrations, and a live connection. It is cheap to clone; clones share the same connection.
  • Migrator applies migrations against a Config.

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 and database_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.

  • FileMigration and EmbeddedMigration are wrapped by default. Opt out with no_transaction() (both directions) or a -- migrant:no-transaction directive in the SQL (per direction). A directive in the SQL takes precedence over the builder flag.
  • FnMigration runs 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-]+.