Early-stage. Core engine is stable and tested in production use. Public API and file format may change before 1.0.
A deterministic, offline-first migration engine for PostgreSQL, written in Rust and inspired by Django migrations.
Declare your schema as YAML, SQL DDL, or Rust structs. Gaman diffs that desired state against replayed migration history and writes the next migration without touching a database at plan time.
Pronounced guh-MUN (गमन, /ɡəˈmən/) — Sanskrit for "movement" or "going forward".
Gaman treats migration planning as a pure replay problem.
It takes the schema you want now, reconstructs the previous schema by replaying every existing migration, and diffs those two states. Because both sides are reduced to the same internal Schema, there is no hidden database state involved in generation.
schema.yaml ─┐
schema.sql ─┼─► Schema (IR) ──┐
Rust structs ─┘ ├─► diff ──► new migration file
migrations/ (replayed) ──────────┘
That is what makes the engine deterministic: the same schema input and the same migration history always produce the same next migration.
Migrations form a DAG. Each file declares its dependencies, so parallel feature branches can meet in an explicit merge migration:
0001_initial → 0002_add_auth ──┐
→ 0003_add_posts ─┴→ 0004_merge
When using multi-crate composition, make_migration automatically infers cross-namespace dependencies by tracking which namespace last touched each entity — so you rarely need to write dependencies by hand.
cargo install gamanPoint Gaman at a database, a migrations directory, and a schema file:
DATABASE_URL=postgres://localhost/myapp
MIGRATIONS_DIR=migrations
SCHEMA_FILE=schema.yaml # or schema.sqlgaman make_migration initial # diff schema -> write migration
gaman sql_migrate # preview SQL without a database
gaman migrate # apply pending migrationsTypical loop:
- Change the schema.
- Run
gaman make_migration add_whatever_changed. - Review the generated YAML or
gaman sql_migrateoutput. - Run
gaman migrate.
[dependencies]
gaman = "0.3"Use the library when you want migrations to ship with your binary. embedded_migrations!("path") embeds every .yaml file at compile time; MigrationEngine runs them.
use gaman::{Config, EmbeddedMigrations, MigrationEngine, embedded_migrations};
static MIGRATIONS: EmbeddedMigrations = embedded_migrations!("migrations");
fn main() {
let n = MigrationEngine::new(Config::default(), &MIGRATIONS)
.migrate()
.expect("migrations failed");
if n > 0 {
eprintln!("{n} migration(s) applied");
}
}MigrationEngine also exposes check(), plan(), show_migrations(), verify(), inspect_db(), make_migration(), and handle_args() for the full CLI surface. See docs/embedding.md for the complete API reference.
Gaman supports composing migrations across crate boundaries at compile time.
If your application is split into library crates — an auth crate, a billing crate, a core domain crate — each one can own and embed its own migrations. The app crate assembles them into a single static tree and hands it to MigrationEngine. No runtime file discovery, no separate config, no coordination between crates on naming.
// auth crate
pub static MIGRATIONS: EmbeddedMigrations = embedded_migrations!("migrations");
// billing crate
pub static MIGRATIONS: EmbeddedMigrations = embedded_migrations!("migrations");
// app crate — assembles the full tree
static MIGRATIONS: EmbeddedMigrations = EmbeddedMigrations {
children: &[
("auth", &auth::MIGRATIONS),
("billing", &billing::MIGRATIONS),
],
..embedded_migrations!("migrations")
};
// One call migrates everything.
MigrationEngine::new(Config::default(), &MIGRATIONS).migrate()?;Each child's migration IDs and dependencies are automatically namespaced (auth/0001_init, billing/0001_plans) so crates never collide. Children can themselves have children — the tree can be arbitrarily deep. Everything is resolved at compile time; the final binary contains the complete ordered graph with no file I/O at startup.
All schema inputs become the same internal Schema, so the format choice is mostly about how you want to author it.
YAML is the most explicit format. It works well when you want schema, indexes, views, functions, and extensions in one place.
extensions:
pgcrypto: {}
tables:
users:
columns:
- name: id
type: bigserial
nullable: false
primary_key: true
- name: email
type: text
nullable: false
- name: created_at
type: timestamptz
nullable: false
default: "now()"
indexes:
- name: users_email_idx
columns: [email]
unique: trueInline shorthands such as primary_key, references, and check are normalized before diffing. SCHEMA_FILE can point to a .yaml, a .sql, or a directory; when you pass a directory, Gaman merges files in alphabetical order.
SQL is useful when you already maintain schema DDL by hand and want Gaman to diff from that source of truth.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX users_email_idx ON users (email);Supported statements today are CREATE TABLE, CREATE [UNIQUE] INDEX, CREATE VIEW, CREATE FUNCTION, CREATE EXTENSION, and CREATE TYPE AS ENUM.
Rust structs fit best when the application already owns the schema in code.
use gaman::IntoTable;
#[derive(IntoTable)]
#[table(name = "users", schema = "app")]
struct User {
id: i64,
email: String,
bio: Option<String>,
#[column(type = "timestamptz")]
created_at: chrono::DateTime<chrono::Utc>,
#[column(type = "uuid", nullable)]
invite_code: Option<uuid::Uuid>,
#[column(default = "0")]
score: i32,
#[column(references = "orgs.id")]
org_id: i64,
}The common derive controls are #[table(name = "...")], #[table(schema = "...")], #[column(type = "...")], #[column(nullable)], #[column(default = "...")], #[column(references = "table.col")], and #[column(skip)].
For the full derive reference, see docs/rust-structs.md.
make_migration writes readable YAML. Most migrations stay auto-generated; you only edit them by hand when the change needs something explicit.
id: 0003_add_posts
dependencies: [0002_add_users]
atomic: true
operations:
- type: create_table
table:
name: posts
columns:
- { name: id, type: bigserial, nullable: false, primary_key: true }
- { name: title, type: text, nullable: false }
- { name: author_id, type: bigint, nullable: false }
foreign_keys:
- name: fk_posts_author
columns: [author_id]
to_table: users
to_column: idEvery migration runs in one transaction by default. Set atomic: false only for PostgreSQL operations that cannot run transactionally, most notably CREATE INDEX CONCURRENTLY.
id: 0004_add_search_idx
dependencies: [0003_add_posts]
atomic: false
operations:
- type: add_index
table_name: posts
index:
name: posts_title_idx
columns: [title]
unique: false
concurrent: trueWhen concurrent: true is present, Gaman validates that atomic: false is also set.
Sometimes the generated migration is structurally correct but still needs a hand-written step. Use statement to embed raw SQL that runs inside the migration's transaction:
operations:
- type: statement
up: "UPDATE users SET role = 'member' WHERE role IS NULL"
down: "UPDATE users SET role = NULL WHERE role = 'member'"The diff engine stays conservative on purpose. A renamed column looks exactly like a drop plus an add unless you tell the tool otherwise. Before writing a migration, Gaman flags ambiguous changes and, in interactive mode, asks for confirmation.
Fatal:NotNullAddandNotNullChange, where the migration would fail or needs a backfill first.Warning:TypeCast, where the change needs an explicit cast or relies on PostgreSQL coercion.Suggestion:RenameColumnandRenameTable, where a human likely meant rename rather than drop and recreate.
For NotNullChange, a backfill UPDATE is automatically injected before the ALTER COLUMN.
The complete command reference, flag details, and environment variables now live in docs/cli.md.
The short version:
gaman make_migration namewrites the next migration by diffing your schema against replayed history.gaman migrateapplies pending migrations.gaman sql_migrateprints SQL without touching a database.gaman verify_dbcompares the live database against replayed state.gaman inspect_dbbootstraps aschema.yamlfrom an existing database.
Global overrides stay the same: -m <dir>, -s <file>, -d <url>.
cargo test
# Offline transform cases.
cargo test --test offline
# Integration tests require a running PostgreSQL instance.
export TEST_DATABASE_URL=postgres://localhost/gaman_test
cargo test --test postgres -- --include-ignoredIntegration tests create and destroy isolated schemas automatically; they leave no lasting state.
Offline transform cases live under tests/cases/offline/, and PostgreSQL-backed cases live under tests/cases/postgres/. Each case is a single case.yaml with inline inputs and expected outputs.
PostgreSQL only. Core migration engine is stable and used in production. Public API may change before 1.0.
- Single-column primary and foreign keys only
- Column order is not tracked
verify_dbdoes not validate view, function, extension, or enum definitionsalter_enumhas no inverse, so migrations containing it cannot be rolled back