Database migrations are essential for managing schema changes in production applications. As your application evolves, your database schema must evolve with it - adding tables, modifying columns, creating indexes. This post covers migration strategies and tools for Go applications, with a focus on safe, reversible changes.
Why Migrations Matter
flowchart LR
subgraph Problem["Without Migrations"]
D1[Dev DB] --> |Different Schema| P1[Prod DB]
P1 --> |Manual Changes| R1[Risk of Errors]
end
subgraph Solution["With Migrations"]
M1[Migration 001] --> M2[Migration 002]
M2 --> M3[Migration 003]
M3 --> D2[All DBs Identical]
end
style Problem fill:#ffcdd2
style Solution fill:#c8e6c9
Migration benefits:
Version control: Track schema changes like code
Reproducibility: Same schema across all environments
Rollback capability: Undo changes when needed
Team collaboration: Clear history of who changed what
Migration Tools for Go
Popular Options
Tool
Features
Best For
golang-migrate
SQL-based, many drivers
Production systems
goose
Go/SQL migrations
Flexible migrations
GORM AutoMigrate
Automatic from structs
Development only
Atlas
Declarative + versioned
Modern approach
golang-migrate Setup
1 2 3 4 5 6 7
# Install CLI go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# Install library go get -u github.com/golang-migrate/migrate/v4 go get -u github.com/golang-migrate/migrate/v4/database/postgres go get -u github.com/golang-migrate/migrate/v4/source/file
CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(50) UNIQUENOT NULL, email VARCHAR(255) UNIQUENOT NULL, password_hash TEXT NOT NULL, is_active BOOLEANDEFAULTtrue, created_at TIMESTAMPWITHTIME ZONE DEFAULT NOW(), updated_at TIMESTAMPWITHTIME ZONE DEFAULT NOW() );
CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_username ON users(username);
000001_create_users.down.sql:
1 2 3
DROP INDEX IF EXISTS idx_users_username; DROP INDEX IF EXISTS idx_users_email; DROPTABLE IF EXISTS users;
Example Migration: Add Column
000002_add_user_profile.up.sql:
1 2 3
ALTER TABLE users ADDCOLUMN profile JSONB DEFAULT'{}'; ALTER TABLE users ADDCOLUMN last_login TIMESTAMPWITHTIME ZONE; CREATE INDEX idx_users_profile ON users USING GIN (profile);
000002_add_user_profile.down.sql:
1 2 3
DROP INDEX IF EXISTS idx_users_profile; ALTER TABLE users DROPCOLUMN IF EXISTS last_login; ALTER TABLE users DROPCOLUMN IF EXISTS profile;
Running Migrations
CLI Commands
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
# Run all pending migrations migrate -database "postgres://user:pass@localhost:5432/myapp?sslmode=disable" \ -path migrations up
# Run specific number of migrations migrate -database "..." -path migrations up 2
# Rollback last migration migrate -database "..." -path migrations down 1
# Rollback all migrations migrate -database "..." -path migrations down
# Check current version migrate -database "..." -path migrations version
# Force version (use with caution!) migrate -database "..." -path migrations force 3
-- Migration 1: Add new column ALTER TABLE users ADDCOLUMN full_name VARCHAR(255); UPDATE users SET full_name = name;
-- Migration 2: (After app is updated to use full_name) ALTER TABLE users DROPCOLUMN name;
Removing Columns
1 2 3 4
-- Safe: Remove in multiple steps -- Step 1: Stop using column in application -- Step 2: Remove column ALTER TABLE users DROPCOLUMN IF EXISTS legacy_field;
Adding Indexes Concurrently
1 2 3 4
-- For large tables, use CONCURRENTLY to avoid locking CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
-- Note: Cannot be run inside a transaction
Migration in Production
Zero-Downtime Migration Strategy
sequenceDiagram
participant App as Application
participant DB as Database
Note over App,DB: Phase 1: Prepare
App->>DB: Migration: Add nullable column
Note over App: App writes to both columns
Note over App,DB: Phase 2: Backfill
App->>DB: Backfill old data
Note over App: App reads from new column
Note over App,DB: Phase 3: Cleanup
App->>DB: Migration: Drop old column
Note over App: App uses only new column
Pre-deployment Checks
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
funcValidateMigration(db *sql.DB)error { // Check for pending migrations m, _ := migrate.New("file://migrations", dbURL) version, dirty, err := m.Version() if err != nil { return err }
if dirty { return errors.New("database is in dirty state - manual intervention required") }
// Truncate original _, err = tx.Exec(fmt.Sprintf("TRUNCATE TABLE %s", tableName)) if err != nil { return err }
// Restore from backup _, err = tx.Exec(fmt.Sprintf( "INSERT INTO %s SELECT * FROM %s", tableName, backupName, )) if err != nil { return err }
return tx.Commit() }
Best Practices Checklist
Practice
Description
Always write down migrations
Every up needs a down
Test rollbacks locally
Before deploying to production
Small, focused migrations
One logical change per migration
No data operations in schema migrations
Separate data migrations
Use transactions
Except for concurrent index creation
Backup before major changes
Especially for data migrations
Version control migrations
Treat as code
Review migrations in PRs
Extra scrutiny for schema changes
Summary
flowchart TD
subgraph Lifecycle["Migration Lifecycle"]
C[Create Migration] --> T[Test Locally]
T --> R[Review in PR]
R --> S[Stage Environment]
S --> P[Production Deploy]
P --> M[Monitor]
M -->|Issues| RB[Rollback]
end
style Lifecycle fill:#e3f2fd
Comments