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