Database Migration with GORM (PostgreSQL)
Migration runner for PostgreSQL that applies up and down migrations with version tracking using GORM models.
main.go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
package main
import (
"fmt"
"log"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Migration represents a database migration with version and up/down functions
type Migration struct {
Version string
Up func(tx *gorm.DB) error
Down func(tx *gorm.DB) error
}
// MigrationRecord tracks applied migrations in the database
type MigrationRecord struct {
gorm.Model
Version string `gorm:"uniqueIndex;not null"` // Migration version (e.g., "20240101000000")
}
// MigrateUp applies all pending up migrations
func MigrateUp(db *gorm.DB, migrations []Migration) error {
// Auto-migrate the migration record table first
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration table: %v", err)
}
// Get applied migrations
var applied []MigrationRecord
if err := db.Find(&applied).Error; err != nil {
return fmt.Errorf("failed to fetch applied migrations: %v", err)
}
// Build map of applied versions for quick lookup
appliedVersions := make(map[string]bool)
for _, m := range applied {
appliedVersions[m.Version] = true
}
// Apply pending migrations
for _, migration := range migrations {
if appliedVersions[migration.Version] {
log.Printf("Migration %s already applied - skipping", migration.Version)
continue
}
// Run migration in transaction
log.Printf("Applying migration %s (UP)", migration.Version)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := migration.Up(tx); err != nil {
return fmt.Errorf("up migration %s failed: %v", migration.Version, err)
}
// Record migration as applied
return tx.Create(&MigrationRecord{Version: migration.Version}).Error
}); err != nil {
return err
}
log.Printf("Successfully applied migration %s", migration.Version)
}
return nil
}
// MigrateDown rolls back the last applied migration
func MigrateDown(db *gorm.DB, migrations []Migration) error {
// Get last applied migration
var lastMigration MigrationRecord
if err := db.Order("created_at desc").First(&lastMigration).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Println("No migrations to roll back")
return nil
}
return fmt.Errorf("failed to fetch last migration: %v", err)
}
// Find corresponding migration definition
var targetMigration *Migration
for _, m := range migrations {
if m.Version == lastMigration.Version {
targetMigration = &m
break
}
}
if targetMigration == nil {
return fmt.Errorf("migration %s not found in definition list", lastMigration.Version)
}
// Run down migration in transaction
log.Printf("Rolling back migration %s (DOWN)", lastMigration.Version)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := targetMigration.Down(tx); err != nil {
return fmt.Errorf("down migration %s failed: %v", lastMigration.Version, err)
}
// Delete migration record
return tx.Delete(&lastMigration).Error
}); err != nil {
return err
}
log.Printf("Successfully rolled back migration %s", lastMigration.Version)
return nil
}
// Example Migrations
var exampleMigrations = []Migration{
{
Version: "20240101000000",
Up: func(tx *gorm.DB) error {
// Create users table
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;not null;size:50"`
Email string `gorm:"uniqueIndex;not null;size:100"`
Age int `gorm:"default:0"`
}
return tx.AutoMigrate(&User{})
},
Down: func(tx *gorm.DB) error {
// Drop users table
return tx.Migrator().DropTable("users")
},
},
{
Version: "20240102000000",
Up: func(tx *gorm.DB) error {
// Add bio column to users table
return tx.Migrator().AddColumn("users", "bio")
},
Down: func(tx *gorm.DB) error {
// Remove bio column from users table
return tx.Migrator().DropColumn("users", "bio")
},
},
}
// Example Usage
func main() {
// PostgreSQL DSN (Data Source Name) - set via environment variable in production
dsn := os.Getenv("POSTGRES_DSN")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=test port=5432 sslmode=disable TimeZone=UTC"
}
// Initialize GORM DB
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // Enable SQL logging
})
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
// Parse command line arguments
if len(os.Args) < 2 {
log.Fatal("Usage: go run main.go [up|down]")
}
subcommand := os.Args[1]
switch subcommand {
case "up":
if err := MigrateUp(db, exampleMigrations); err != nil {
log.Fatalf("migrate up failed: %v", err)
}
log.Println("All migrations applied successfully")
case "down":
if err := MigrateDown(db, exampleMigrations); err != nil {
log.Fatalf("migrate down failed: %v", err)
}
log.Println("Migration rolled back successfully")
default:
log.Fatalf("unknown subcommand: %s (use 'up' or 'down')", subcommand)
}
}How It Works
Migration runner for PostgreSQL that applies up and down migrations with version tracking using GORM models.
Defines a Migration model with version and checksum, connects via GORM, ensures the table exists, checks the current version, applies pending up scripts inside a transaction, and supports rolling back with down scripts while logging progress.
Key Concepts
- 1Version table prevents reapplying migrations and records checksums.
- 2Transactional execution ensures partial migrations roll back cleanly.
- 3Supports both upgrading and downgrading via paired migration steps.
When to Use This Pattern
- Rolling schema changes safely through staging and production.
- Embedding migrations inside services without external tools.
- CI pipelines that need deterministic database setup.
Best Practices
- Run migrations inside transactions when the database supports it.
- Keep migration files idempotent and auditable.
- Backup or snapshot databases before destructive downgrades.
Go Version1.17
Difficultyintermediate
Production ReadyYes
Lines of Code181