Vow is a lightweight, opinionated migration runner for Go projects using pgx and PostgreSQL.
It is designed to be embedded directly into your application, ensuring your database schema is up-to-date before your service starts.
I wanted to understand how database migration tools work internally. Having already used tools like Goose and Flyway, I didn't build Vow because I was missing a tool, I wanted to see what happens underneath the abstraction. I built a migration runner from scratch while working on dhara.
Once it was working, I extracted it from dhara into Vow to reuse it across my other Go projects. Dhara now uses Vow for its own migrations. This turned a learning exercise into a small, reusable library I can use whenever I need PostgreSQL migrations in a Go project.
- Zero Dependencies: Only relies on
pgx/v5. - Safety First: Uses PostgreSQL advisory locks to ensure multiple service instances don't run migrations simultaneously.
- Simple: No configuration files or complex CLI tools. Just code.
- 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 := vow.New(dbPool, "./migrations",
vow.WithTableName("app_schema_migrations"),
vow.WithLogger(myCustomLogger),
)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.
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. On everyUporDownrun, before anything else happens, Vow re-reads every already-applied file and compares hashes: if a file was edited or deleted after being applied, the run fails immediately, 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.