gomigr is a PostgreSQL DSL migrator written in Go. It applies and reverts
migrations in topological order based on depends-on declarations rather than
plain filename sorting. It ships both as a CLI binary (cmd/gomigr) and as a
Go library (root package github.com/therox/gomigr).
go install github.com/therox/gomigr/cmd/gomigr@latest
The binary needs Go 1.21+. The only runtime dependencies are
github.com/jackc/pgx/v5/stdlib and gopkg.in/yaml.v3.
Sources are merged in this order (later sources override earlier ones):
- Built-in defaults:
migrations_dir = "./migrations". There is no default DSN — it must be provided. - YAML file at
--config <path>(default./configs/config.yaml). If the file is missing, this step is silently skipped. - Environment variables:
GOMIGR_DSN,GOMIGR_MIGRATIONS_DIR. - CLI flags:
--dsn,--migrations-dir.
GOMIGR_USER (read by the CLI as one of the applied_by sources) and
GOMIGR_LOG_LEVEL (logger level) live outside this merge — they are not
overridden by YAML and have no CLI-flag equivalents in the merge chain.
--to, --steps, --all, --yes are per-command arguments for up/down,
not persistent settings.
The state table name (schema_migrations) is not configurable — it is hard-coded.
Example YAML:
dsn: "postgres://user:pass@localhost:5432/app?sslmode=disable"
migrations_dir: "./migrations"File name: <version>_<snake_case_description>.sql, where <version> is a
sortable identifier (integer or date such as 20260601). <version> is the
migration's unique identifier (UNIQUE constraint in schema_migrations);
the primary key of the table is the surrogate id column described below.
-- depends-on: 20260701
-- depends-on: 20260702, 20260703
-- +migrate Up
CREATE TABLE users (...);
-- +migrate Down
DROP TABLE users;Parsing rules:
-- depends-on:comments may appear 0..N times anywhere before-- +migrate Up. A single line may list several versions separated by commas.- Markers
-- +migrate Upand-- +migrate Downare mandatory. - Everything between them is the Up section; everything after
-- +migrate Downis the Down section. An empty Down section is allowed (logged as a warning). - Sections are passed to
pgxas-is. Semicolons inside string literals and$$ ... $$blocks are not split — multi-statement bodies execute within one transaction. - Checksum is
sha256of the normalized file content.
gomigr up [--config path] [--dsn ...] [--migrations-dir path] [--user name] [--to V | --steps N]
gomigr down [--config path] [--dsn ...] [--migrations-dir path] [--user name] (--to V | --steps N | --all [--yes])
gomigr status [--config path] [--dsn ...] [--migrations-dir path]
gomigr create [--config path] [--migrations-dir path] <name>
-
upwithout flags applies all pending migrations in topological order.--to Vrestricts the batch to versions<= Vplus their transitive pending dependencies; transitive dependencies with version greater thanVare still applied and logged as warnings.--toand--stepsare mutually exclusive. -
up --steps Napplies only the firstNpending migrations in topological order. Topo order guarantees dependencies come before dependents, so the first-Nprefix is self-contained — no migration in the prefix can have a dependency that was cut off.Nmust be a positive integer; the CLI rejects an explicit--steps 0and negative values (the library-levelUpOptions{Steps: 0}still means "no limit", for symmetry with emptyTo). IfN >= len(pending), all pending are applied. -
downrequires an explicit scope — runninggomigr downwithout any of--to,--steps,--allexits with code 1 and the messagespecify --to <version>, --steps <N> or --all. The three flags are mutually exclusive: combining any two prints--to, --steps, --all are mutually exclusiveand exits with code 1. -
down --to Vreverts all applied migrations whose version is> Vin reverse topological order (a dependent migration is reverted before the one it depends on). -
down --steps Nreverts theNmost recently applied migrations, ordered byidinschema_migrations(chronological apply order, not lexicographicversionorder).Nmust be a positive integer; the CLI rejects an explicit--steps 0and negative values with--steps must be a positive integer. Cascade is followed: if any of the startingNhas applied dependents that fall outside the starting set, those dependents are pulled in and aWARNis logged for each extra migration. The final batch is executed in reverse topological order. -
down --allreverts every applied migration. In an interactive terminal it printsThis will revert ALL applied migrations. Type 'yes' to continue:to stderr and reads a line from stdin; the operation proceeds only when the trimmed, lowercased input equalsyes. Any other input aborts with exit code 1 andaborted by user. In a non-interactive context (pipe, CI),--allwithout--yesexits with code 1 and--all requires --yes in non-interactive mode. Passing--yesskips the prompt entirely.--yesonly makes sense alongside--all; with--toor--stepsit is silently ignored. -
statusprints a table of currently applied migrations in chronological apply order (sorted byid) with columnsID,VERSION,NAME,STATUS,APPLIED_BY,APPLIED_AT.IDis the sequential number of the row inschema_migrations(BIGSERIAL, hands out values in the order migrations were applied) — it does not coincide withVERSIONwhendepends-onreordered the natural lexicographic sequence.APPLIED_ATis rendered as RFC 3339 in UTC. Example output:ID VERSION NAME STATUS APPLIED_BY APPLIED_AT 1 20260101 create_users applied sergey 2026-05-15T18:42:01Z 2 20260201 create_posts applied sergey 2026-05-15T18:42:01Z 3 20260301 add_audit_trigger applied sergey 2026-05-15T18:42:02Z 4 20260401 seed_demo_data checksum_mismatch sergey 2026-05-15T18:42:02Z -
create <name>writes<UTC timestamp>_<name>.sqlinto the migrations directory with an empty Up/Down template.<name>must match^[a-z0-9][a-z0-9_]*$. This command does not touch the database.
applied_by resolution order:
--user <name>flagGOMIGR_USERenvironment variable- Current OS user via
os/user.Current() - fallback
"unknown"
- Natural order is lexicographic by version.
- A
depends-on: Bdeclaration in migration A forces B to be applied before A, even ifversion(B) > version(A). - Cycles are reported as an error listing the cycle vertices.
gomigrdoes not attempt to break them. - A dangling dependency (version listed in
depends-on:but not present anywhere on disk or inschema_migrations):- Detected before the batch starts:
gomigrprintsmissing dependency: <V> required by <A>to stdout, logs an error and exits with code 2 without applying anything depending on the missing one. - Detected mid-batch: the current migration is not applied, the same line is printed to stdout, and previously committed migrations remain in the database (no global rollback). Exit code 2.
- Detected before the batch starts:
CREATE TABLE IF NOT EXISTS schema_migrations (
id BIGSERIAL PRIMARY KEY,
version TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
status TEXT NOT NULL,
applied_by TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);idis aBIGSERIALsurrogate key whose values reflect the chronological order in which migrations were applied.versionis the migration identifier from the file name. It is keptUNIQUE, which preserves the "one row per version" invariant that used to be enforced viaPRIMARY KEY.- The
statuscolumn storesappliedorchecksum_mismatch. - A
pg_try_advisory_lockis held for the wholeup/downrun, preventing concurrent invocations against the same database (ErrLockBusyis reported if the lock is busy). - Each migration applies/reverts inside its own transaction together with the
matching
INSERT/DELETEonschema_migrations. There is no global rollback: a failure on the N-th migration leaves the first N-1 applied. - When the checksum of a file differs from the value stored in the table,
gomigrdoes not stop. It logs an error and updates the row's status tochecksum_mismatch, then continues with the rest of the batch.
log/slog, text format. Level is taken fromGOMIGR_LOG_LEVEL(debug|info|warn|error, defaultinfo).- Logged events: current DB version on start,
applying/applied,reverting/reverted. If there is nothing to do, a singleNo migrationline is emitted and the process exits with code 0. - Exit codes:
0success,1user or configuration error,2migration execution error.
The root package exposes a small facade. The library opens its own
short-lived *sql.DB against pgx/v5/stdlib, performs the work and closes
it on Close() — the host application's pool is never touched.
package main
import (
"context"
"log/slog"
"os"
"github.com/therox/gomigr"
)
func main() {
ctx := context.Background()
m, err := gomigr.New(ctx, gomigr.Options{
DSN: os.Getenv("DATABASE_URL"),
MigrationsDir: "./migrations",
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
})
if err != nil {
panic(err)
}
defer m.Close()
if err := m.Up(ctx, gomigr.UpOptions{}); err != nil {
panic(err)
}
}Migrator.Up and Migrator.Down take UpOptions / DownOptions structs
rather than a single toVersion string. The library deliberately refuses
to guess scope on Down — an empty DownOptions{} returns
ErrDownScopeRequired instead of reverting everything.
type UpOptions struct {
To string // upper version bound (inclusive); "" means no bound
Steps int // limit pending applies; 0 means no limit; <0 is an error
}
type DownOptions struct {
To string // revert applied with version > To
Steps int // revert N most recently applied migrations (by id); >0
All bool // revert every applied migration (explicit opt-in)
}Exactly one of DownOptions.To, DownOptions.Steps, DownOptions.All
must be set (To != "", Steps > 0, or All == true). Otherwise:
- empty
DownOptions{}→ErrDownScopeRequired - two or three fields set →
errors.New("gomigr: DownOptions fields To, Steps, All are mutually exclusive") Steps < 0(Up or Down) → validation error before any DB call
Three Down examples:
// Revert everything (matches the old behaviour of Down(ctx, "")).
err := m.Down(ctx, gomigr.DownOptions{All: true})
// Revert applied migrations with version > "20260301".
err := m.Down(ctx, gomigr.DownOptions{To: "20260301"})
// Revert the 3 most recently applied migrations (plus any applied
// dependents that get pulled in by cascade — those are logged at WARN).
err := m.Down(ctx, gomigr.DownOptions{Steps: 3})UpOptions{Steps: 0} is intentionally equivalent to no limit (symmetric
with To == ""); CLI-side validation rejects --steps 0 separately,
because at the CLI layer the only reason to pass the flag is to set a
positive cap.
Error sentinels exported from the package (match with errors.Is):
ErrLockBusy— anothergomigrinstance is holding the advisory lock for this database.ErrDownScopeRequired—Migrator.Downwas called with an emptyDownOptions{}; the library refuses to default to "revert everything".
The library never reads environment variables, never parses YAML and never
calls os.Exit. All input is passed through Options; all output goes to
the supplied *slog.Logger (a nil logger turns into a discard handler).
Unit tests run with the default Go toolchain: go test ./.... They do not
require Docker.
Integration tests live under the integration build tag and spin up a real
PostgreSQL via testcontainers-go. Docker must be available locally:
go test -tags=integration ./...
The default go test ./... invocation stays green even when Docker is not
installed.
- PostgreSQL only — no other databases.
- No dry-run, no Go-coded migrations, no seed data, no
golang-migratecompatibility layer. Use this tool only if you accept the format above. - Each migration is its own atomic unit; failing migration N leaves N-1 migrations applied. Manual cleanup is your responsibility.
downrefuses to revert anything while there is an applied migration whose file is missing frommigrations_dir: itsdepends-onis lost with the file, so cascade integrity cannot be verified. Restore the file or cleanschema_migrationsmanually before retrying.