go / migrate
I use plain SQL migration files in db/migrate/, applied
by a small Go runner. No ORM, no DSL, no external migration
tool. Each version is a pair of files (*.up.sql + optional
*.down.sql) that own their own BEGIN/COMMIT. The runner
sends each file as one simple-query batch.
Migration files
Each version lives as a pair under db/migrate/:
db/migrate/20260430165330_clear_bad_ai_tam_estimates.up.sql
db/migrate/20260430165330_clear_bad_ai_tam_estimates.down.sql
The .down.sql is optional. Drop it to mark a migration
non-reversible.
A transactional migration wraps statements in BEGIN/COMMIT:
BEGIN;
DELETE FROM tam_estimates
WHERE updated_by_user_id = 1 AND tam_usd >= 200000000000;
COMMIT;
A CREATE INDEX CONCURRENTLY migration omits BEGIN/COMMIT
and sets lock_timeout = '0' on the first line:
SET lock_timeout = '0';
CREATE INDEX CONCURRENTLY index_people_on_email
ON people (email);
The SQL file owns its transaction stance. The runner doesn't wrap and doesn't branch.
Generator
A genmigration subcommand scaffolds the file pair:
go run ./cmd/db genmigration add_status_to_jobs
go run ./cmd/db genmigration --concurrent people email
The transactional form scaffolds a BEGIN/COMMIT skeleton.
The --concurrent <table> <column> form prefills CREATE
INDEX CONCURRENTLY with the index name
index_<table>_on_<column>, since that's the dominant
non-transactional case in practice.
const (
transactionalUpScaffold = `BEGIN;
-- write your changes here
COMMIT;
`
transactionalDownScaffold = `BEGIN;
-- reverse of the up file; delete this file if not reversible
COMMIT;
`
)
var (
concurrentUpScaffold = template.Must(template.New("up").Parse(
`-- One statement only. SET lock_timeout = 0 so CREATE INDEX
-- CONCURRENTLY can complete its multi-phase lock acquisition.
SET lock_timeout = '0';
CREATE INDEX CONCURRENTLY index_{{.Table}}_on_{{.Column}} ON {{.Table}} ({{.Column}});
`))
concurrentDownScaffold = template.Must(template.New("down").Parse(
`SET lock_timeout = '0';
DROP INDEX CONCURRENTLY IF EXISTS index_{{.Table}}_on_{{.Column}};
`))
)
Runner
The runner reads files from disk at startup. No //go:embed:
deploys ship the binary plus the migration directory.
(Embedding trades that for one fewer thing to deploy; either
is fine: swap os.ReadFile/os.ReadDir for
embed.FS.ReadFile/ReadDir.)
const (
migrateDir = "db/migrate"
upSuffix = ".up.sql"
downSuffix = ".down.sql"
stampLen = 14
undefinedTbl = "42P01" // PostgreSQL undefined_table
)
type migration struct {
upPath string
downPath string
}
runMigrate lints first, opens a dedicated connection, sets
safety timeouts, and applies pending migrations:
func runMigrate(ctx context.Context) error {
if err := lintBeforeRun(); err != nil {
return err
}
conn, err := connectMigrate(ctx)
if err != nil {
return err
}
defer conn.Close(ctx)
if err := setMigrateTimeouts(ctx, conn); err != nil {
return err
}
files, err := loadMigrations(migrateDir)
if err != nil {
return err
}
applied, err := appliedVersions(ctx, conn)
if err != nil {
return err
}
for _, ver := range pendingVersions(files, applied) {
m := files[ver]
sql, err := os.ReadFile(m.upPath)
if err != nil {
return fmt.Errorf("read %s: %w", m.upPath, err)
}
if err := applyMigration(ctx, conn, injectBookkeeping(string(sql), ver, false)); err != nil {
return fmt.Errorf("migrate %s: %w", ver, err)
}
// Reset session-scoped GUCs the migration may have changed
// (most commonly `SET lock_timeout = '0';` in CONCURRENTLY).
if err := setMigrateTimeouts(ctx, conn); err != nil {
return fmt.Errorf("reset timeouts after %s: %w", ver, err)
}
fmt.Printf("pg migrated %s\n", ver)
}
return dumpSchema()
}
Three details worth calling out:
Dedicated connection. Use pgx.Connect(ctx, url) directly,
not a pool. Migrations rely on session-level SET commands
(lock_timeout, statement_timeout) persisting across the
whole run; pools can't guarantee that.
Simple-query batch. Send the file as one batch via
conn.PgConn().Exec(ctx, sql).ReadAll(). The pgx extended
protocol creates an implicit transaction per query, which
breaks CONCURRENTLY. The simple protocol just hands the
string to Postgres and reads results back.
func applyMigration(ctx context.Context, conn *pgx.Conn, sql string) error {
if strings.TrimSpace(sql) == "" {
return nil
}
_, err := conn.PgConn().Exec(ctx, sql).ReadAll()
return err
}
Reset timeouts between migrations. A CONCURRENTLY
migration sets lock_timeout = '0' to disable the safety
net. Without re-applying the default after the migration
runs, a subsequent transactional migration on the same
connection silently runs without the 10s timeout.
func setMigrateTimeouts(ctx context.Context, conn *pgx.Conn) error {
if _, err := conn.Exec(ctx, "SET lock_timeout = '10s'"); err != nil {
return fmt.Errorf("set lock_timeout: %w", err)
}
if _, err := conn.Exec(ctx, "SET statement_timeout = '10min'"); err != nil {
return fmt.Errorf("set statement_timeout: %w", err)
}
return nil
}
Bookkeeping
The runner records each applied version in schema_migrations
(create the table in your first migration with version text
PRIMARY KEY). For transactional files, splice the INSERT
before the final COMMIT; so the migration and its
bookkeeping commit atomically:
var commitLineRE = regexp.MustCompile(`(?im)^\s*COMMIT\s*;\s*$`)
// injectBookkeeping splices an INSERT (or DELETE on rollback)
// onto the line above the file's final COMMIT;, so the
// bookkeeping rides the migration's transaction.
func injectBookkeeping(sql, version string, rollback bool) string {
var stmt string
if rollback {
stmt = fmt.Sprintf("DELETE FROM schema_migrations WHERE version = '%s';", version)
} else {
stmt = fmt.Sprintf("INSERT INTO schema_migrations (version) VALUES ('%s');", version)
}
locs := commitLineRE.FindAllStringIndex(sql, -1)
if len(locs) == 0 {
// CONCURRENTLY file: no COMMIT to splice into; append.
// Bookkeeping then runs auto-committed, leaving a small
// race where a crash between the index build and the
// INSERT leaves the version unrecorded. Recovery is
// manual.
if strings.TrimSpace(sql) == "" {
return stmt + "\n"
}
return strings.TrimRight(sql, "\n") + "\n" + stmt + "\n"
}
last := locs[len(locs)-1]
return sql[:last[0]] + stmt + "\n" + sql[last[0]:]
}
version is a validated 14-digit timestamp (see
splitFilename below), so string interpolation here is safe.
Connection guard
A small belt-and-suspenders guard refuses to run migrations
against a non-localhost host unless APP_ENV names a deployed
environment:
func guardConnectionTarget(dsn, appEnv string) error {
cfg, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("parse DATABASE_URL: %w", err)
}
switch cfg.Host {
case "localhost", "127.0.0.1", "::1":
return nil
}
switch appEnv {
case "production", "staging":
return nil
}
return fmt.Errorf("refusing to run migrations against non-localhost host %q. "+
"Set APP_ENV=production or APP_ENV=staging to name the deployed environment",
cfg.Host)
}
The blast radius of an accidental production migration run is the whole production database. Cheap explicit guard.
File parsing
Filenames must be <14-digit-stamp>_<name>.{up,down}.sql.
Anything else (READMEs, stray editor files) is ignored:
func splitFilename(name string) (version, direction string, ok bool) {
var suffix string
switch {
case strings.HasSuffix(name, upSuffix):
suffix, direction = upSuffix, "up"
case strings.HasSuffix(name, downSuffix):
suffix, direction = downSuffix, "down"
default:
return "", "", false
}
base := strings.TrimSuffix(name, suffix)
if len(base) <= stampLen+1 || base[stampLen] != '_' {
return "", "", false
}
for i := 0; i < stampLen; i++ {
if c := base[i]; c < '0' || c > '9' {
return "", "", false
}
}
return base[:stampLen], direction, true
}
A version with only a .down.sql (no .up.sql) is dropped
during load. It can never be applied or rolled back
coherently.
Linter
migrate and rollback lint the migration directory before
opening any connection. There's no separate lint
subcommand; unsafe migrations never reach the database in
the first place.
Rules apply at the file level. Each rule has a pattern
regex (the unsafe shape), an optional exclude regex (a
paired safe pattern that lets the file pass), and an optional
skipIfCreateTable flag (greenfield indexes and FKs in
CREATE TABLE files are safe):
type lintRule struct {
name string
pattern *regexp.Regexp
exclude *regexp.Regexp
skipIfCreateTable bool
message string
}
var lintRules = []lintRule{
{
name: "non-concurrent-index",
pattern: regexp.MustCompile(`(?i)CREATE\s+(UNIQUE\s+)?INDEX\s+`),
exclude: regexp.MustCompile(`(?i)CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY`),
skipIfCreateTable: true,
message: "CREATE INDEX without CONCURRENTLY blocks writes. " +
"Use CREATE INDEX CONCURRENTLY in a separate non-transactional file.",
},
{
name: "fk-without-not-valid",
pattern: regexp.MustCompile(`(?i)REFERENCES\s+\w+\s*\([^)]+\)`),
exclude: regexp.MustCompile(`(?i)NOT\s+VALID`),
skipIfCreateTable: true,
message: "REFERENCES without NOT VALID blocks writes on both tables. " +
"Add NOT VALID, then VALIDATE in a separate migration.",
},
{
name: "set-not-null",
pattern: regexp.MustCompile(`(?i)ALTER\s+(TABLE\s+\w+\s+)?ALTER\s+(COLUMN\s+)?\w+\s+SET\s+NOT\s+NULL`),
message: "SET NOT NULL blocks reads/writes while checking every row. " +
"Use a CHECK constraint with NOT VALID instead.",
},
{
name: "check-without-not-valid",
pattern: regexp.MustCompile(`(?i)ADD\s+CONSTRAINT\s+\w+\s+CHECK\s*\([^)]+\)`),
exclude: regexp.MustCompile(`(?i)NOT\s+VALID`),
message: "CHECK constraint without NOT VALID blocks reads/writes. " +
"Add NOT VALID, then VALIDATE in a separate migration.",
},
}
See postgres / safe migrations for why each pattern is unsafe.
Strip comments before matching
Match against content with -- line comments stripped, so
-- TODO: switch to CREATE INDEX CONCURRENTLY doesn't make
the file appear to use CONCURRENTLY when it doesn't:
// stripLineComments replaces every `--` line comment with
// spaces of equal length, preserving newlines and overall
// byte length so line numbers and offsets line up with the
// original content.
func stripLineComments(s string) string {
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
if i+1 < len(s) && s[i] == '-' && s[i+1] == '-' {
for i < len(s) && s[i] != '\n' {
b.WriteByte(' ')
i++
}
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
Equal-length replacement keeps line numbers in error messages aligned with the original file.
File-level coherence checks
Beyond per-rule pattern matching, two file-wide checks on
.up.sql:
hasBegin := beginRE.MatchString(stripped)
hasCommit := commitRE.MatchString(stripped)
switch {
case hasConcurrently && (hasBegin || hasCommit):
// CONCURRENTLY can't run inside a transaction. Hard error.
case !hasConcurrently && !hasCreateTable && (!hasBegin || !hasCommit):
// .up.sql without CREATE TABLE should wrap in BEGIN/COMMIT.
// Warning, not error.
}
The first is a hard error: PostgreSQL rejects CONCURRENTLY inside a transaction block. The second is a warning that catches missing transaction wrappers.
CLI
One binary with subcommands:
go run ./cmd/db migrate # apply pending
go run ./cmd/db rollback [steps] # revert last N (default 1)
go run ./cmd/db genmigration <name>
go run ./cmd/db genmigration --concurrent <table> <column>
migrate and rollback lint first; genmigration writes
files and stops. There's no separate dblint because lint
is part of migrate/rollback.
Schema dump
After every successful migrate/rollback in development,
regenerate db/schema.sql:
func dumpSchema() error {
if env.String("APP_ENV", "development") != "development" {
return nil
}
url := env.String("DATABASE_URL", defaultDatabaseURL)
out, err := os.Create(filepath.Join("db", "schema.sql"))
if err != nil {
return fmt.Errorf("create db/schema.sql: %w", err)
}
defer out.Close()
cmd := exec.Command("pg_dump", "--schema-only", "--no-owner", "--no-acl", url)
cmd.Stdout = out
cmd.Stderr = os.Stderr
return cmd.Run()
}
db/schema.sql is checked in. Reviewers see the full
schema diff alongside the migration SQL in PRs.
When to use
- Single Go service or service group sharing a Postgres database
- SQL files (no DSL, no ORM-driven migrations) with transparent control over BEGIN/COMMIT and CONCURRENTLY
- Comfortable shipping the migration directory alongside the
binary (or swapping in
embed.FS)
When not to use
- Multi-tenant or sharded migrations where per-tenant runners need different orchestration
- Teams that prefer migration tooling shipped with an ORM (sqlc + goose, ent, GORM)