Vow is a PostgreSQL migration runner for Go, built on pgx. It started as the internal migration engine for dhara and was extracted into a standalone library and CLI for reuse across other Go/pgx services.
Embed it in your application and call Up at startup to bring the schema up to date before the service starts.
Quick Start • Command Line • Migration File Format • Design Notes
vow-demo-github.mp4
- One direct dependency:
pgx/v5. - Safety First: Uses PostgreSQL advisory locks to ensure multiple service instances don't run migrations simultaneously.
- Configured in code: No config files; behavior is set with functional options.
- Immutable: Enforces strict validation that applied migrations cannot be renamed, deleted, or edited (checksums are verified on every run).
- Reversible: Every migration is paired with a
.down.sqlcounterpart, validated up front.
-
Install:
go get github.com/md-talim/vow
-
Usage: Create a directory (e.g.,
./migrations) and add paired.up.sql/.down.sqlfiles.import ( "context" "log" "os" "github.com/md-talim/vow" ) func main() { // ... initialize your pgxpool.Pool ... migrator, err := vow.New(dbPool, os.DirFS("./migrations")) if err != nil { log.Fatalf("invalid migrations directory: %v", err) } if _, err := migrator.Up(context.Background()); err != nil { log.Fatalf("failed to migrate: %v", err) } }
Down rolls back the N most recently applied migrations, in reverse order. Each rollback runs its paired .down.sql file and removes the tracking row in a single transaction.
// Roll back the single most recent migration
result, err := migrator.Down(context.Background(), 1)
if err != nil {
log.Fatalf("rollback failed: %v", err)
}
log.Printf("rolled back: %v", result.Versions)If steps exceeds the number of applied migrations, Vow rolls back everything and stops; it will not error.
Down is intended for local development and test teardown, not automated production recovery. If Up fails mid-deploy, Vow does not attempt to roll back automatically; it fails loudly and leaves the decision (fix-forward vs. manual rollback) to a human. See Design Notes below.
Both Up and Down return a Result alongside the error, reporting what actually happened:
type Result struct {
Versions []string // migrations applied (Up) or rolled back (Down), in order
Skipped int // already-applied migrations left untouched (Up only; always 0 for Down)
Duration time.Duration // total wall-clock time for the run
}On a partial failure (e.g. migration 3 of 5 fails), Result still reflects whatever succeeded before the error; check both return values, not just the error.
result, err := migrator.Up(ctx)
if err != nil {
log.Printf("migration run failed after applying %v: %v", result.Versions, err)
return err
}
log.Printf("applied %d, skipped %d, took %s", len(result.Versions), result.Skipped, result.Duration)You can customize the migration behavior using functional options:
migrator, err := vow.New(dbPool, os.DirFS("./migrations"),
vow.WithTableName("app_schema_migrations"),
vow.WithLogger(myCustomLogger),
)
if err != nil {
log.Fatalf("invalid migrations directory: %v", err)
}Vow accepts any fs.FS, so migration files can be compiled directly into your binary with //go:embed, which is useful when shipping migrations as part of a library, where consumers won't have your migrations/ folder on disk.
import (
"embed"
"io/fs"
"github.com/md-talim/vow"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func newMigrator(pool *pgxpool.Pool) (*vow.Migrator, error) {
// //go:embed retains the migrations/ directory in the resulting
// fs.FS. Root it with fs.Sub so Vow sees the .sql files directly.
rooted, err := fs.Sub(migrationFS, "migrations")
if err != nil {
return nil, err
}
return vow.New(pool, rooted)
}Vow always assumes the fs.FS it's given is already rooted at the migrations directory; it never does subdirectory resolution internally. os.DirFS("./migrations") is already rooted correctly; an embed.FS from //go:embed migrations/*.sql is not, and needs fs.Sub first. This keeps the rooting decision in one place at construction time, rather than repeated as a directory argument on every call.
Vow ships with a minimal CLI, vow, for running migrations from the terminal. It is handy for local development and test teardown, where running migrations by hand beats embedding Vow in a throwaway program.
Install it with:
go install github.com/md-talim/vow/cmd/vow@latestUsage:
vow <command> [flags]
Commands:
up Apply all pending migrations
down Roll back the N most recently applied migrations (default 1)
up applies every pending migration and prints a summary:
DATABASE_URL=postgres://user:pass@localhost:5432/mydb vow up
# applied 2, skipped 0, took 45msdown rolls back the N most recently applied migrations in reverse order. N defaults to 1:
vow down # roll back the single most recent migration
vow down 3 # roll back the three most recent migrationsBoth commands accept the same flags:
--database-urlPostgreSQL connection URL. Defaults to$DATABASE_URL.--migrations-dirPath to the migrations directory. Defaults to./migrations.--table-nameName of the migration tracking table. Defaults toschema_migrations.--lock-nameName of the advisory lock. Defaults tovow:migrations.
Run vow <command> -h for command-specific help. vow exits with status 0 on success, 1 on failure, and 130 if interrupted.
Migration files must be named using a numeric prefix to ensure ordering, paired with matching .up.sql and .down.sql files:
000001_create_users.up.sql000001_create_users.down.sql000002_add_email_index.up.sql000002_add_email_index.down.sql
New() validates the migrations directory up front:
- Every
.up.sqlmust have a matching.down.sql, and vice versa. - Filenames must match the
NNNNNN_name.up.sql/NNNNNN_name.down.sqlpattern. - Subdirectories are not allowed inside the migrations directory.
A malformed directory fails at construction time, not later when a migration or rollback is actually attempted.
- Applied migrations are checksummed, not just tracked by filename. Every
.up.sqlfile's SHA-256 hash is stored alongside its version at apply time (line endings are normalized to LF first, so a CRLF checkout does not look like drift). On everyUporDownrun, Vow re-reads every already-applied file and compares hashes before applying or rolling back anything: if a file was edited or deleted after being applied, the run fails before touching the database further. - Down migrations are not checksummed. When a migration is rolled back, Vow deletes its tracking row. Because no permanent record of the rollback exists, a checksum is unnecessary; there is no stored state left to drift. This is deliberate: rollbacks often happen because a migration needs to change, so down-files are intentionally left free to edit.
- Down migrations are best-effort, not guaranteed-reversible. A
.down.sqlfile can undo a schema shape change, but not necessarily recover data lost by the corresponding.up.sql(e.g. a dropped column). Write and review down migrations with that limitation in mind. Upnever callsDownautomatically. If a migration fails partway through a deploy, Vow fails loudly and stops rather than attempting an automatic rollback; an already-failed state is the wrong moment for the tool to attempt a risky, unverified undo. That decision is left to a human.
Contributions are welcome! Please feel free to open issues or submit pull requests for any improvements or bug fixes.