Migrations with golang-migrate & goose
Schema changes belong in versioned migration files, not ad hoc ALTER in application startup.
golang-migrate and goose are popular Go-friendly tools that apply ordered SQL (or Go) migrations and support rollbacks in CI and deploy pipelines.
Summary
Migrations are timestamped or sequenced files with up and down sections.
Apply migrations before new code serves traffic, or use expand-contract patterns for zero-downtime deploys.
Run migration jobs in CI against ephemeral databases to catch SQL errors early.
Never run migrations inside per-request HTTP handlers.
Recipe
Quick-reference recipe card - copy-paste ready.
# golang-migrate CLI
migrate -path ./migrations -database "${DATABASE_URL}" up
migrate -path ./migrations -database "${DATABASE_URL}" down 1
# goose CLI
goose -dir ./migrations postgres "$DATABASE_URL" up
goose -dir ./migrations postgres "$DATABASE_URL" down// embed migrations (see sibling article)
//go:embed migrations/*.sql
var migrationFS embed.FSWhen to reach for this:
- Any team-managed SQL schema shared across environments.
- Deploy pipelines that must fail if schema drift exists.
- Rollback drills where
downmigrations restore prior shape in staging.
Working Example
package main
import (
"database/sql"
"fmt"
"log"
"github.com/pressly/goose/v3"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
db, err := sql.Open("pgx", "postgres://localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
goose.SetDialect("postgres")
if err := goose.Up(db, "migrations"); err != nil {
log.Fatal(err)
}
fmt.Println("migrations applied")
}Example migrations/00001_create_users.sql:
-- +goose Up
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
-- +goose Down
DROP TABLE users;What this demonstrates:
goose.Upapplies pending files in lexical order.- Each file documents forward and reverse steps.
SetDialectselects SQL quirks for Postgres, MySQL, or SQLite.- Migration runs as a deploy hook or init container, not per HTTP request.
Deep Dive
How It Works
- Tools track applied versions in a metadata table (
schema_migrationsor goose version table). upruns pending files;downreverses the latest or N steps.- golang-migrate names files
000001_init.up.sqland000001_init.down.sql. - goose supports SQL and Go migrations for data backfills that need loops.
CI and Deploy Flow
| Step | Owner | Action |
|---|---|---|
| PR | Developer | Add migration + app code in same PR |
| CI | Pipeline | Spin ephemeral DB, up, run tests, optional down |
| Staging | Release | up before rolling pods |
| Production | Release | up with backup snapshot; monitor locks |
Zero-Downtime Patterns
- Expand: add nullable column or new table; deploy code that writes both old and new.
- Migrate data: backfill job outside request path.
- Contract: remove old column after traffic uses new shape.
Breaking changes (NOT NULL without default, rename in place) need multi-phase releases.
golang-migrate vs goose
| Tool | Strength | Trade-off |
|---|---|---|
| golang-migrate | Widely used, CLI + library | Go migrations need separate package layout |
| goose | Go migrations in same module | Team must agree on annotation format |
| GORM AutoMigrate | Fast prototypes | Weak review story for production schema |
Gotchas
- Editing applied migration files - Environments diverge silently. Fix: new forward-only migration to correct state.
- Missing down migration - Rollback drills fail. Fix: require
downin review for reversible changes; document irreversible ops. - Long-running migration in deploy timeout - Deploy kills job mid-ALTER. Fix: run heavy migrations as separate maintenance job with lock monitoring.
- App startup AutoMigrate in prod - Race across replicas; untracked diffs. Fix: explicit migration job with version table.
- No CI database - Broken SQL reaches production. Fix: ephemeral Postgres service in pipeline applying
upbefore tests. - Destructive down in production - Data loss on rollback. Fix: treat
downas staging-only; prod rollbacks restore backups.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Flyway/Liquibase (JVM) | Polyglot org standard | Pure Go shop wants embedded migrations |
| GORM AutoMigrate | Hackathon schema | Audited production schema |
| Manual DBA runbooks | Rare touch mainframes | Fast-moving product teams |
| sqlc without migrations | Queries only | You still need schema versioning somewhere |
| Atlas / skeema | Declarative desired state | Team prefers imperative up/down files |
FAQs
Should migrations run in the app binary?
Library embed plus Up on boot is acceptable for small services.
Larger teams prefer a Kubernetes Job or release hook so app pods start only after schema is ready.
How do I name files?
Use zero-padded sequence or UTC timestamps; never reuse numbers.
One logical change per file eases review.
Can I mix goose and golang-migrate?
Pick one tool per repository to avoid competing version tables.
Migrating tools is a one-time project with a freeze window.
What about seed data?
Separate seed scripts or goose Go migrations for dev fixtures.
Production seeds belong in idempotent jobs, not random SQL in up.
How do rollbacks work in prod?
Often forward-fix with a new migration instead of down.
down is most valuable in CI and developer laptops.
Do migrations need context?
CLI and jobs may use context.Background() with ops-controlled cancel.
Do not tie schema changes to HTTP request ctx.
How do I test migrations?
CI: up, integration tests, down, up again to detect non-idempotent scripts.
Snapshot row counts for data migrations.
What permissions should the migration user have?
DDL-capable role used only in deploy job; app runtime uses lesser privileges.
Separate users reduce blast radius.
How does embed fit?
See embed for SQL Migrations & Seed Data for shipping files inside the binary.
Are Go migrations worth it?
Useful for complex backfills with logging and batching.
Keep simple DDL in SQL for DBA reviewability.
Related
- embed for SQL Migrations & Seed Data - bundle files in the binary
- Databases Basics - SQL execution basics
- Transactions, Prepared Statements & sql.Tx - DDL transaction limits
- GORM vs sqlx vs Raw database/sql - AutoMigrate trade-offs
- Databases & Data Access Best Practices - migration CI rules
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).