The deployment that breaks the database
You push a new version of your service. The build passes. The tests pass. The container starts up. Then the first request hits the database and the logs fill with pq: column "updated_at" does not exist. You added the column on your laptop. You forgot to run the migration script on the server. Or the script ran, but the order was wrong, and a dependent table was created before its parent. Database drift turns a minor schema change into a production outage.
Migrations solve this by making schema changes explicit, versioned, and reproducible. You write a script that describes the change, assign it a version number, and the tool ensures the script runs exactly once, in the correct order, on every instance of your database. The database keeps a ledger of what has run. If the ledger says version 5 and you have files up to 7, the tool runs 6 and 7. If the ledger says 7 and you roll back to version 5, the tool runs the reverse scripts for 7 and 6.
Migrations are code. Treat them like code.
How migration tools work
A migration tool manages a table in your database, usually named schema_migrations. This table stores the version number and a checksum of every migration that has been applied. When you run the tool, it connects to the database, reads the schema_migrations table, and compares the stored version against the migration files on disk.
The tool sorts the files by version number. It identifies the gap between the database state and the file state. It executes the SQL for the missing versions, one by one. If a migration succeeds, the tool records the version in the ledger. If a migration fails, the tool stops immediately. It does not run subsequent migrations. The database remains in the state it was before the failed migration.
Think of a migration like a patch file for a blueprint. You don't redraw the entire building every time you add a room. You write a note saying "Add a bathroom on the second floor" and hand it to the construction crew. The crew applies the note, updates the master blueprint, and moves on. If the crew runs into a problem, they stop. They don't leave a half-built bathroom. They roll back and fix the issue.
The ledger is your source of truth. Never edit a migration that has already run.
Minimal example: SQL files and the CLI
The most common approach in Go is to store migrations as plain SQL files. This keeps the schema logic portable and readable. Tools like golang-migrate expect files named with a version number and a direction.
migrations/
001_create_users.up.sql
001_create_users.down.sql
002_add_email.up.sql
002_add_email.down.sql
The .up.sql file contains the SQL to apply the change. The .down.sql file contains the SQL to reverse the change.
-- 001_create_users.up.sql
-- Create the users table with an auto-incrementing ID.
-- SERIAL handles the sequence creation automatically in PostgreSQL.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- 001_create_users.down.sql
-- Drop the users table to reverse the migration.
-- CASCADE removes dependent objects if they exist.
DROP TABLE IF EXISTS users CASCADE;
You can run migrations from the command line using the migrate tool. Install the binary and point it at your migration directory and database connection string.
go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate -path ./migrations -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" up
The up command applies all pending migrations. The tool reads the schema_migrations table, finds that version 0 is recorded, and runs 001_create_users.up.sql. It then runs 002_add_email.up.sql. It records version 2 in the ledger.
If you run up again, the tool sees that the database is already at version 2. It does nothing. This idempotency is essential for safe deployments.
The schema_migrations table is your source of truth. Trust it.
Running migrations from Go code
In many Go applications, you want the binary to manage the schema automatically. This ensures that every instance of the service starts with the correct database structure. You embed the migration logic in your startup sequence.
package main
import (
"log"
"os"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
// RunMigrations executes pending database migrations.
// It blocks until all migrations are applied or an error occurs.
func RunMigrations(dsn string) error {
// Initialize the migration instance with the file source and database driver.
// The blank imports register the postgres driver and file source for the migrate library.
m, err := migrate.New("file://migrations", dsn)
if err != nil {
return err
}
// Run all pending migrations.
// The tool handles version tracking, ordering, and transaction wrapping automatically.
return m.Up()
}
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL is required")
}
// Run migrations before starting the HTTP server.
// If migrations fail, the application should not start.
if err := RunMigrations(dsn); err != nil {
log.Fatalf("migration failed: %v", err)
}
log.Println("migrations applied successfully")
// Start the server...
}
The blank imports _ "github.com/golang-migrate/migrate/v4/database/postgres" are idiomatic Go. They trigger the side effect of registering the PostgreSQL driver with the migration library. Without the blank import, the compiler rejects the program with an undefined error when you try to use the driver, or the runtime returns a database driver not found error because the driver was never registered.
The migrate.New function takes two arguments: the source of the migrations and the database connection string. The source can be a local directory, a Git repository, or embedded assets. The connection string uses the same format as the database/sql driver.
The m.Up() method applies all pending migrations. It returns an error if any migration fails. The error message usually includes the version number and the SQL error, which helps you debug the issue.
Never edit a migration that has already run. Create a new migration instead.
Pitfalls and production realities
Migrations look simple until you hit production. Several patterns cause pain if you ignore them.
DDL transactions vary by database. PostgreSQL wraps Data Definition Language statements in transactions. If a migration fails halfway through, PostgreSQL rolls back the entire transaction. The database stays consistent. MySQL does not always support DDL transactions. A failed migration might leave a half-created table or a missing index. You need to check your database documentation and test rollbacks carefully.
Rollbacks must be tested. The .down.sql file is not optional. If you skip the down migration, you cannot roll back a deployment. You are stuck with the broken state until you write a new migration to fix it. Test your down migrations in a staging environment. Verify that running down restores the database to the previous state.
Large tables block the database. Adding a column to a table with millions of rows can lock the table for minutes or hours. During the lock, no reads or writes can happen. Your application hangs. Users see errors. Use the expand/contract pattern for large tables. Add the new column as nullable. Backfill the data in batches using a background job. Switch the application code to use the new column. Drop the old column in a later migration. This avoids long locks and keeps the service available.
Context cancellation is missing. Most migration libraries do not support context.Context. You cannot cancel a migration with a deadline. If a migration hangs, you have to kill the process. This is a limitation of the tools, not Go. Be aware that migrations are long-running operations that bypass the normal context plumbing. Monitor migration duration and set timeouts at the process level.
Idempotency is not guaranteed by the tool. The tool prevents running the same version twice. It does not make the SQL idempotent. If you run CREATE TABLE on a table that already exists, the SQL fails. The tool relies on the ledger to prevent this. If you delete the ledger or run the tool against a fresh database with old files, the tool might try to create tables that already exist. Use CREATE TABLE IF NOT EXISTS cautiously. It can hide errors where the schema is different from what you expect.
The worst migration bug is the one that locks the table for an hour. Plan for large tables.
Decision matrix
Use the golang-migrate CLI when you want a language-agnostic workflow that developers can run from the terminal without building the application.
Use the migrate library embedded in your Go code when you want the binary to self-heal by applying pending migrations automatically on startup.
Use a custom migration runner when you need complex logic, such as data transformations that require application-level code, or when you must integrate migrations into a larger deployment orchestration system.
Use raw SQL scripts managed by your CI/CD pipeline when you want to separate schema changes from application code and enforce strict approval gates before changes reach production.
Use an ORM's built-in migration feature when you are already using an ORM and don't mind vendor lock-in, but verify that the ORM supports your database's specific DDL syntax and transaction behavior.
Migrations are cheap to write and expensive to fix. Write them carefully.