A simple, powerful SQL migration tool for Go with support for both SQL and Go migrations.
Amigo provides a clean API for managing database migrations with built-in CLI support and programmatic access. Write migrations in SQL or Go, control transactions, and get real-time feedback during execution.
- SQL and Go migrations - Write migrations in SQL files or Go code
- Embedded migrations - SQL files are embedded in binary via
embed.FSfor portability - Transaction control - Fine-grained control over transaction behavior
- Multiple database support - PostgreSQL, SQLite, ClickHouse drivers included
- CLI tool - Built-in CLI for managing migrations
- Programmatic API - Use migrations directly in your Go code
- Standard library only - No external dependencies
go get github.com/alexisvisco/amigoCreate the following structure:
yourapp/
├── cmd/
│ └── migrate/
│ └── main.go
├── migrations/
│ └── migrations.go
└── go.mod
Create cmd/migrate/main.go:
package main
import (
"database/sql"
"log"
"os"
"github.com/alexisvisco/amigo"
"yourapp/migrations"
_ "modernc.org/sqlite"
)
func main() {
db, err := sql.Open("sqlite", "app.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
config := amigo.DefaultConfiguration
config.DB = db
config.Driver = amigo.NewSQLiteDriver("")
// Load migrations
migrationList := migrations.Migrations(config)
cli := amigo.NewCLI(amigo.CLIConfig{
Config: config,
Migrations: migrationList,
Directory: "migrations",
DefaultTransactional: true,
DefaultFileFormat: "sql",
})
os.Exit(cli.Run(os.Args[1:]))
}Create migrations/migrations.go:
package migrations
import (
"embed"
"github.com/alexisvisco/amigo"
)
//go:embed *.sql
var sqlFiles embed.FS
func Migrations(cfg amigo.Configuration) []amigo.Migration {
return []amigo.Migration{}
}Note: SQL migrations are embedded using embed.FS, making your migration binary portable with no external SQL files needed.
go run cmd/migrate/main.go generate create_users_tableThis creates migrations/20240101120000_create_users_table.sql:
-- migrate:up tx=true
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
-- migrate:down tx=true
DROP TABLE users;And automatically updates migrations/migrations.go:
package migrations
import (
"embed"
"github.com/alexisvisco/amigo"
)
//go:embed *.sql
var sqlFiles embed.FS
func Migrations(cfg amigo.Configuration) []amigo.Migration {
return []amigo.Migration{
amigo.SQLFileToMigration(sqlFiles, "20240101120000_create_users_table.sql", cfg),
}
}The //go:embed *.sql directive embeds all SQL files into the binary, and SQLFileToMigration takes the embedded filesystem as its first argument.
# Apply all pending migrations
go run cmd/migrate/main.go up
# View status
go run cmd/migrate/main.go statusgo build -o bin/migrate cmd/migrate/main.go
# Use it
./bin/migrate up
./bin/migrate status# Generate SQL migration
go run cmd/migrate/main.go generate create_users_table
# Generate Go migration
go run cmd/migrate/main.go generate --format=go add_email_validation# Apply all pending migrations
go run cmd/migrate/main.go up
# Apply next 2 migrations
go run cmd/migrate/main.go up --steps=2
# Skip confirmation
go run cmd/migrate/main.go up --yes# Revert last migration
go run cmd/migrate/main.go down
# Revert last 3 migrations
go run cmd/migrate/main.go down --steps=3
# Revert all migrations
go run cmd/migrate/main.go down --steps=-1
# Skip confirmation
go run cmd/migrate/main.go down --yesgo run cmd/migrate/main.go statusOutput:
Migration Status: 2 applied, 1 pending
Status Date Name Applied At
pending 20240103100000 add_comments
applied 20240102150000 add_posts 2024-01-02 15:30:45
applied 20240101120000 create_users 2024-01-01 12:05:23
go run cmd/migrate/main.go show-configYou can run migrations directly in your Go code without using the CLI:
package main
import (
"context"
"database/sql"
"fmt"
"log"
"github.com/alexisvisco/amigo"
"yourapp/migrations"
_ "modernc.org/sqlite"
)
func main() {
// Open database
db, err := sql.Open("sqlite", "app.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Configure amigo
config := amigo.DefaultConfiguration
config.DB = db
config.Driver = amigo.NewSQLiteDriver("schema_migrations")
// Load migrations
migrationList := migrations.Migrations(config)
// Create runner
runner := amigo.NewRunner(config)
ctx := context.Background()
// Run all pending migrations
err = runner.Up(ctx, migrationList)
if err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
fmt.Println("Migrations applied successfully!")
}Use iterators for real-time progress:
// Run migrations with progress feedback
for result := range runner.UpIterator(ctx, migrationList) {
if result.Error != nil {
log.Fatalf("Migration failed: %v", result.Error)
}
fmt.Printf("✓ %s (%.2fs)\n", result.Migration.Name(), result.Duration.Seconds())
}// Revert last migration
err = runner.Down(ctx, migrationList, amigo.RunnerDownOptionSteps(1))
// Revert with progress
for result := range runner.DownIterator(ctx, migrationList, amigo.RunnerDownOptionSteps(1)) {
if result.Error != nil {
log.Fatalf("Revert failed: %v", result.Error)
}
fmt.Printf("✓ Reverted %s (%.2fs)\n", result.Migration.Name(), result.Duration.Seconds())
}statuses, err := runner.GetMigrationsStatuses(ctx, migrationList)
if err != nil {
log.Fatal(err)
}
for _, status := range statuses {
if status.Applied {
fmt.Printf("✓ %s (applied at %s)\n",
status.Migration.Name,
status.Migration.AppliedAt.Format("2006-01-02 15:04:05"))
} else {
fmt.Printf("○ %s (pending)\n", status.Migration.Name)
}
}SQL migrations use annotations to separate up and down migrations:
-- migrate:up tx=true
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT
);
CREATE INDEX idx_posts_title ON posts(title);
-- migrate:down tx=true
DROP TABLE posts;Control transaction behavior per migration:
-- migrate:up tx=false
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- migrate:down tx=false
DROP INDEX CONCURRENTLY idx_users_email;Go migrations give you full programmatic control:
package migrations
import (
"context"
"database/sql"
"github.com/alexisvisco/amigo"
)
type Migration20240101120000CreateUsers struct{}
func (m Migration20240101120000CreateUsers) Name() string {
return "create_users"
}
func (m Migration20240101120000CreateUsers) Date() int64 {
return 20240101120000
}
func (m Migration20240101120000CreateUsers) Up(ctx context.Context, db *sql.DB) error {
return amigo.Tx(ctx, db, func(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)
`)
return err
})
}
func (m Migration20240101120000CreateUsers) Down(ctx context.Context, db *sql.DB) error {
return amigo.Tx(ctx, db, func(tx *sql.Tx) error {
_, err := tx.Exec(`DROP TABLE users`)
return err
})
}Use ChainExecTx to chain multiple SQL statements without repetitive error handling:
func (m Migration20240101120000CreateUsers) Up(ctx context.Context, db *sql.DB) error {
return amigo.Tx(ctx, db, func(tx *sql.Tx) error {
return amigo.NewChainExecTx(ctx, tx).
Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`).
Exec(`CREATE INDEX idx_users_name ON users(name)`).
Exec(`INSERT INTO settings (key, value) VALUES ('version', '1')`).
Err()
})
}Or use ChainExec for non-transactional operations:
func (m Migration20240101120000CreateUsers) Up(ctx context.Context, db *sql.DB) error {
return amigo.NewChainExec(ctx, db).
Exec(`CREATE INDEX CONCURRENTLY idx_users_email ON users(email)`).
Exec(`CREATE INDEX CONCURRENTLY idx_users_created_at ON users(created_at)`).
Err()
}func (m Migration20240101120000CreateUsers) Up(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `CREATE INDEX CONCURRENTLY idx_users_email ON users(email)`)
return err
}config := amigo.Configuration{
DB: db,
Driver: driver,
SQLFileUpAnnotation: "-- migrate:up",
SQLFileDownAnnotation: "-- migrate:down",
}cliConfig := amigo.CLIConfig{
Config: config,
Migrations: migrationList,
Output: os.Stdout,
ErrorOut: os.Stderr,
Directory: "db/migrations",
DefaultTransactional: true,
DefaultFileFormat: "sql",
}
cli := amigo.NewCLI(cliConfig)import (
"github.com/alexisvisco/amigo"
_ "github.com/lib/pq"
)
driver := amigo.NewPostgresDriver("schema_migrations")import (
"github.com/alexisvisco/amigo"
_ "modernc.org/sqlite"
)
driver := amigo.NewSQLiteDriver("schema_migrations")import (
"github.com/alexisvisco/amigo"
_ "github.com/ClickHouse/clickhouse-go/v2"
)
// For standalone ClickHouse
driver := amigo.NewClickHouseDriver("schema_migrations", "")
// For clustered ClickHouse
driver := amigo.NewClickHouseDriver("schema_migrations", "{cluster}")Note: When using a cluster, the driver creates a ReplicatedReplacingMergeTree table and uses soft deletes for migration rollbacks. For standalone setups (empty cluster string), it uses MergeTree and hard deletes.
If you have multiple databases (e.g., PostgreSQL for main data and ClickHouse for analytics), create separate migration CLIs:
package main
import (
"database/sql"
"log"
"os"
"github.com/alexisvisco/amigo"
"yourapp/migrations/postgres"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
config := amigo.DefaultConfiguration
config.DB = db
config.Driver = amigo.NewPostgresDriver("schema_migrations")
migrationList := postgres.Migrations(config)
cli := amigo.NewCLI(amigo.CLIConfig{
Config: config,
Migrations: migrationList,
Directory: "migrations/postgres",
DefaultTransactional: true,
DefaultFileFormat: "sql",
})
os.Exit(cli.Run(os.Args[1:]))
}package main
import (
"database/sql"
"log"
"os"
"github.com/alexisvisco/amigo"
"yourapp/migrations/clickhouse"
_ "github.com/ClickHouse/clickhouse-go/v2"
)
func main() {
db, err := sql.Open("clickhouse", "clickhouse://localhost:9000/default")
if err != nil {
log.Fatal(err)
}
defer db.Close()
config := amigo.DefaultConfiguration
config.DB = db
config.Driver = amigo.NewClickHouseDriver("schema_migrations", "")
migrationList := clickhouse.Migrations(config)
cli := amigo.NewCLI(amigo.CLIConfig{
Config: config,
Migrations: migrationList,
Directory: "migrations/clickhouse",
DefaultTransactional: true,
DefaultFileFormat: "sql",
})
os.Exit(cli.Run(os.Args[1:]))
}yourapp/
├── cmd/
│ ├── migrate-postgres/
│ │ └── main.go
│ └── migrate-clickhouse/
│ └── main.go
├── migrations/
│ ├── postgres/
│ │ ├── migrations.go
│ │ ├── 20240101120000_create_users.sql
│ │ └── 20240102150000_create_orders.sql
│ └── clickhouse/
│ ├── migrations.go
│ ├── 20240101120000_create_events.sql
│ └── 20240102150000_create_analytics.sql
└── go.mod
# PostgreSQL migrations
go run cmd/migrate-postgres/main.go generate create_users
go run cmd/migrate-postgres/main.go up
go run cmd/migrate-postgres/main.go status
# ClickHouse migrations
go run cmd/migrate-clickhouse/main.go generate create_events
go run cmd/migrate-clickhouse/main.go up
go run cmd/migrate-clickhouse/main.go status# Build both migration tools
go build -o bin/migrate-postgres cmd/migrate-postgres/main.go
go build -o bin/migrate-clickhouse cmd/migrate-clickhouse/main.go
# Use them
./bin/migrate-postgres up
./bin/migrate-clickhouse upMigration files follow the format: {timestamp}_{name}.{ext}
- Timestamp:
YYYYMMDDHHMMSS - Name: Snake case description
- Extension:
.sqlor.go
Example: 20240101120000_create_users_table.sql
Use the Tx helper for transactional Go migrations:
err := amigo.Tx(ctx, db, func(tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO users (name) VALUES (?)", "Alice")
if err != nil {
return err
}
_, err = tx.Exec("INSERT INTO posts (title) VALUES (?)", "First Post")
return err
})MIT
Contributions welcome! Please open an issue or PR.