How to Use Database Migrations in Production (golang-migrate, goose, atlas)

Execute database migrations in production using golang-migrate with the up command and a secure connection string.

The midnight schema panic

You deploy a new feature on a Tuesday afternoon. The code expects a user_preferences table with a theme column. The database still holds the old schema from last month. The application crashes on the first request. Or worse, a migration runs halfway through, hits a constraint violation on a duplicate row, and leaves the database in a half-broken state. Production goes dark. The on-call engineer wakes up to a pager that will not stop.

Database migrations solve this by treating schema changes like code. You version them, review them in pull requests, and run them predictably. Instead of manually executing SQL scripts against a live database, you hand the work to a tool that tracks what has run, applies what is missing, and rolls back if something fails. The database and the application stay in sync.

What a migration actually is

A migration is just a SQL file with a timestamp or sequence number. The migration tool keeps a ledger in the database, usually called schema_migrations or goose_db_version. It checks the ledger, finds what has not run yet, and applies them in order. Think of it like a library checkout system. The ledger tracks which books you have read. The tool only hands you the next unread one. When you finish, it stamps the ledger. If the power goes out mid-read, the ledger shows exactly where you stopped.

Go does not include a migration package in the standard library. The ecosystem relies on third-party tools. Pick one and stick with it. The community convention is to keep migration files in a migrations/ directory at the project root. Name them with a timestamp prefix like 20241105143000_create_users.up.sql. The timestamp guarantees ordering without manual numbering. You also write a matching .down.sql file that reverses the change. The tool uses the up file to move forward and the down file to step back.

Migrations are version control for your database. Treat them with the same discipline as your Go code.

A minimal migration in action

Here is the simplest migration pair. The up file creates a table. The down file drops it.

-- 20241105143000_create_users.up.sql
-- Creates the users table with a primary key and a unique email constraint
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Adds a comment to the table for documentation purposes
COMMENT ON TABLE users IS 'Core user accounts for the application';
-- 20241105143000_create_users.down.sql
-- Reverses the up migration by removing the table entirely
DROP TABLE IF EXISTS users;

The tool reads these files, connects to the database, and executes them. Here is how you run it from the command line.

# Runs all pending up migrations against the local PostgreSQL instance
migrate -path ./migrations -database "postgres://localhost:5432/mydb?sslmode=disable" up

The tool connects, queries the tracking table, sees no entries, runs the SQL, wraps it in a transaction, commits, and writes a row to the tracking table. If the SQL contains a syntax error, PostgreSQL rejects it with syntax error at or near "CREAT", and the tool rolls back the transaction. The tracking table stays unchanged. You can run the command again without fear of duplication.

How the tracking table keeps you safe

The tracking table is the single source of truth for your database state. The tool creates it automatically on first run. It stores the migration version, the execution time, and sometimes a hash of the file contents. When you run the tool again, it compares the file system against the tracking table. It only executes files that are missing from the ledger.

PostgreSQL supports DDL inside transactions. That means CREATE TABLE, ALTER TABLE, and DROP TABLE can roll back if something fails. MySQL and SQLite do not support transactional DDL in the same way. If a migration fails on MySQL, the tool cannot automatically undo the changes. You must write the down migration manually and run it yourself. Always verify your database's transactional capabilities before relying on automatic rollbacks.

Convention aside: Error handling in Go is explicit. Migration tools return errors that you must check. The standard pattern is if err != nil { log.Fatal(err) } for CLI tools, or returning the error in library usage. Never swallow migration errors. A silent failure means your database and code are out of sync. The if err != nil boilerplate is verbose by design. The community accepts it because it makes the unhappy path visible.

The tracking table is your safety net. Never delete it manually.

Production-ready migration code

Running migrations from a CLI is fine for local development. In production, you usually run them as part of your application startup or a CI/CD pipeline. Here is how you integrate golang-migrate into a Go service.

package main

import (
    "context"
    "log"
    "os"
    "time"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

// RunMigrations applies pending database schema changes on startup
func RunMigrations(ctx context.Context, dsn string) error {
    // Context carries a timeout to prevent hanging on network issues
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    // Initialize the migration driver with the file source and database URL
    m, err := migrate.NewWithDatabaseInstance(
        "file://migrations",
        "postgres",
        dsn,
    )
    if err != nil {
        return err
    }

    // Apply up to 5 pending migrations to avoid long table locks
    err = m.Steps(5)
    if err != nil && err != migrate.ErrNoChange {
        return err
    }

    return nil
}

The blank imports (_) register the PostgreSQL driver and the file source. Go requires explicit driver registration for database/sql. If you forget to import the driver, the compiler will not catch it, but the runtime panics with database/sql: unknown driver "postgres". The function accepts context.Context as the first parameter. Context is plumbing. Run it through every long-lived call site. Functions that take a context should respect cancellation and deadlines. If the deployment pipeline cancels the job, the migration stops cleanly.

The Steps(5) call limits how many migrations run at once. In production, running dozens of migrations in a single batch can lock tables for too long. Batching them reduces risk. If Steps returns migrate.ErrNoChange, the database is already up to date. You ignore that specific error and treat everything else as a failure.

Convention aside: Public names start with a capital letter. Private start lowercase. No keywords like public or private. The function name RunMigrations follows Go naming conventions. It starts with a capital letter because it is exported. The parameter dsn is lowercase because it is local to the function.

Migrations run once per deployment. Keep them fast, keep them idempotent, and never block the main goroutine indefinitely.

The hidden traps in production

Zero-downtime deployments require careful migration design. Adding a column is usually safe. Renaming a column or changing a data type is not. You cannot rename a column while the application is still reading and writing to it. The old code will break immediately. The standard pattern is a three-step process. First, add the new column. Second, backfill data from the old column to the new one in small batches. Third, update the application to read and write the new column. Fourth, drop the old column in a later deployment. Each step gets its own migration. Each step deploys independently.

Long-running migrations block reads and writes. PostgreSQL locks the table during ALTER TABLE operations in older versions. Modern versions use concurrent metadata updates, but heavy operations still cause contention. If you need to modify a table with millions of rows, consider using pg_repack or online_schema_change tools that rebuild the table in the background. Do not run massive data migrations during peak traffic hours.

If you pass a malformed connection string, migrate.New returns parse "postgres://...": invalid URL escape. If you try to run a migration that references a non-existent table, PostgreSQL rejects it with relation "old_table" does not exist, and the tool rolls back. Always test migrations against a database that matches production data volume and structure. A migration that takes two seconds on your laptop might take twenty minutes in production.

Convention aside: The receiver name is usually one or two letters matching the type. If you wrap the migration tool in a struct, name the receiver (m *Migrator), not (this *Migrator) or (self *Migrator). Go idioms favor brevity. The compiler does not care, but the community does.

The worst migration bug is the one that silently succeeds but corrupts data. Test your down migrations. Verify them in staging. Rollback plans are not optional.

Choosing your migration tool

Use golang-migrate when you want a mature, widely adopted CLI and Go library that supports multiple databases out of the box. Use goose when you prefer Go-based migrations alongside SQL, or when your team wants to run migrations directly from Go code without a separate binary. Use atlas when you need declarative schema management, diff-based migrations, and built-in linting for zero-downtime changes. Use raw SQL scripts with a CI/CD runner when your infrastructure team manages databases separately from application code. Use an ORM auto-migration feature like GORM when you are building a prototype and accept the risk of implicit schema changes in production.

Migrations are infrastructure. Treat them like code, test them like code, and never run them manually in production.

Where to go next