Error Handling
Handle and diagnose errors from go-migration using typed error values, errors.Is(), errors.As(), and a troubleshooting guide for common scenarios.
Error Handling
go-migration returns typed error values for common failure scenarios. This lets you inspect errors programmatically using Go's standard errors.Is() and errors.As() functions, rather than relying on string matching.
Typed Error Values
go-migration exports the following sentinel errors. Each error is defined once, in the package that owns it; the migrator package re-exports the database, seeder, and grammar errors as aliases of the same values, so errors.Is(err, migrator.ErrSeederNotFound) and errors.Is(err, seeder.ErrSeederNotFound) are equivalent:
| Error | Package(s) | Description |
|---|---|---|
ErrMigrationNotFound | migrator | The requested migration name does not exist in the registry |
ErrDuplicateMigration | migrator | A migration with the same name has already been registered |
ErrInvalidMigrationName | migrator | A migration name does not match the expected format |
ErrTrackingTable | migrator | The migration tracking table could not be created, read, or written. The message includes the driver's error (migration tracking table error: <cause>), and the cause stays in the error chain |
ErrLockTimeout | migrator | The cross-process migration lock could not be acquired within the lock timeout, usually because another process is running migrations or seeders (new in v1.2.0) |
ErrTransactionFailed | migrator, database | A transaction (begin, commit, or rollback) failed |
ErrConnectionFailed | migrator, database | The database connection could not be established |
ErrConnectionNotFound | migrator, database | A named connection does not exist |
ErrUnsupportedType | migrator, grammars | A column type is not supported by the active grammar |
ErrConfigValidation | config | Configuration validation failed. No longer re-exported by migrator since v1.2.0 |
ErrDriverNotFound | database | No driver is registered for the given name |
ErrNoDefault | database | No default connection has been set |
ErrSeederNotFound | migrator, seeder | The requested seeder name does not exist in the registry |
ErrDuplicateSeeder | migrator, seeder | A seeder with the same name has already been registered |
ErrInvalidSeederName | migrator, seeder | A seeder name does not match the expected format |
ErrCircularDependency | migrator, seeder | Seeder dependencies form a cycle that cannot be resolved |
These errors are defined in the migrator, database, seeder, config, and schema/grammars packages:
import (
"github.com/gopackx/go-migration/pkg/config"
"github.com/gopackx/go-migration/pkg/database"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
"github.com/gopackx/go-migration/pkg/seeder"
)Removed in v1.2.0: migrator.ErrConfigValidation. The migrator never returned it — it only aliased the config package's error. Use config.ErrConfigValidation instead, so the migrator library no longer depends on the configuration loader.
Using errors.Is()
Use errors.Is() to check whether an error matches a specific typed error. This works even when the error is wrapped with additional context.
package main
import (
"database/sql"
"errors"
"fmt"
"log"
_ "github.com/lib/pq"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:password@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
m := migrator.New(db, migrator.WithGrammar(grammars.NewPostgresGrammar()))
// Register migrations...
if err := m.Up(); err != nil {
switch {
case errors.Is(err, migrator.ErrDuplicateMigration):
log.Println("A migration with the same name is already registered")
case errors.Is(err, migrator.ErrMigrationNotFound):
log.Println("A referenced migration is not registered")
case errors.Is(err, migrator.ErrTransactionFailed):
log.Printf("Migration transaction failed: %v", err)
case errors.Is(err, migrator.ErrConnectionFailed):
log.Println("Could not connect to the database")
default:
log.Fatalf("Unexpected error: %v", err)
}
}
fmt.Println("Migrations applied successfully")
}Checking Rollback Errors
if err := m.Rollback(0); err != nil {
if errors.Is(err, migrator.ErrTransactionFailed) {
log.Printf("Rollback transaction failed: %v", err)
// Investigate the database state manually
} else if errors.Is(err, migrator.ErrMigrationNotFound) {
log.Println("A migration in the batch is no longer registered")
} else {
log.Fatalf("Unexpected rollback error: %v", err)
}
}Checking Lock Timeouts
Operations that change the database wait for the migration lock up to the lock timeout (30s by default, set with migrator.WithLockTimeout). When it elapses, the operation fails with an error wrapping migrator.ErrLockTimeout and nothing has been changed:
if err := m.Up(); err != nil {
if errors.Is(err, migrator.ErrLockTimeout) {
// Another process holds the lock; it is safe to retry later.
log.Printf("migrations are running elsewhere: %v", err)
os.Exit(1)
}
log.Fatal(err)
}Checking Seeder Errors
import (
"errors"
"log"
"github.com/gopackx/go-migration/pkg/seeder"
)
if err := runner.RunAll(); err != nil {
switch {
case errors.Is(err, seeder.ErrSeederNotFound):
log.Println("Seeder not found in the registry")
case errors.Is(err, seeder.ErrCircularDependency):
log.Println("Circular dependency detected between seeders")
default:
log.Fatalf("Seeding failed: %v", err)
}
}Using errors.As()
Use errors.As() to extract a specific error type and access its fields. This is useful when go-migration wraps errors with additional context like the migration name or underlying database error.
package main
import (
"database/sql"
"errors"
"log"
_ "github.com/lib/pq"
"github.com/gopackx/go-migration/pkg/migrator"
"github.com/gopackx/go-migration/pkg/schema/grammars"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:password@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
m := migrator.New(db, migrator.WithGrammar(grammars.NewPostgresGrammar()))
// Register migrations...
if err := m.Up(); err != nil {
var migrationErr *migrator.MigrationError
if errors.As(err, &migrationErr) {
log.Printf("Migration '%s' failed: %v", migrationErr.MigrationName, migrationErr.Cause)
log.Printf("Failing SQL: %s", migrationErr.SQL)
if migrationErr.Position != "" {
log.Printf("Position: %s", migrationErr.Position)
}
} else {
log.Fatalf("Error: %v", err)
}
}
}The MigrationError type provides structured information about the failure:
type MigrationError struct {
MigrationName string // Name of the migration that failed
SQL string // The SQL statement that triggered the failure
Position string // Position information from the DB (optional)
Cause error // The underlying error from the database driver
}errors.As() unwraps the error chain automatically. Even if the error is wrapped multiple times, errors.As() will find the first matching type in the chain.
Wrapping Errors in Migrations
When returning errors from your Up or Down methods, wrap them with fmt.Errorf and the %w verb to preserve the error chain:
func (m *CreateUsersTable) Up(s *schema.Builder) error {
err := s.Create("users", func(bp *schema.Blueprint) {
bp.ID()
bp.String("email", 255).Unique()
bp.Timestamp("created_at")
})
if err != nil {
return fmt.Errorf("failed to create users table: %w", err)
}
return nil
}This ensures callers can use both errors.Is() and errors.As() to inspect the full error chain.
Troubleshooting
Common error scenarios you may encounter when using go-migration:
| Scenario | Cause | Solution |
|---|---|---|
| "migration tracking table error: …" | The migrations tracking table could not be created or read; the text after the colon is the driver's error | Read the driver cause. Ensure the database user has DDL privileges (m.Up() creates the table automatically), that a schema-qualified migration_table points at an existing schema, and that you pass WithGrammar for MySQL/SQLite |
| "dry-run: migration tracking table … does not exist; run migrate:install first" | Dry-run never creates the tracking table | Run migrate:install once (or migrator.NewTrackerWithGrammar(db, table, grammar).EnsureTable()) before using --dry-run / WithDryRun |
| "could not acquire migration lock within 30s" | Another process holds the migration lock (ErrLockTimeout) | Wait for the other process, raise --lock-timeout / WithLockTimeout, or check for a stuck process holding the lock |
| "migration lock: the connection pool is limited to 1 open connection" | Locking is enabled on PostgreSQL/MySQL with MaxOpenConns = 1 | Set max_open_conns to 0 or at least 2, or disable locking with --no-lock / WithoutLock() |
| "invalid description: … must be lower snake_case" | A make:migration, make:seeder, or make:factory name is not lower snake_case | Use a name matching ^[a-z][a-z0-9_]*$, e.g. create_orders_table |
| "duplicate migration name" | Two migrations registered with the same name via m.Register() | Ensure each migration has a unique timestamp-prefixed name (e.g., 20240101000001_create_users_table) |
| "migration not found" | Calling m.Rollback() or referencing a migration name that isn't registered | Verify the migration is registered with m.Register() before running operations |
| "database connection refused" | The database server is not running or the connection string is incorrect | Check that the database is running, verify host/port/credentials, and test with db.Ping() |
| "transaction deadlock" | A migration and application queries are waiting on each other's locks | Concurrent migration runs are already serialised by the migration lock; for long-running DDL that conflicts with application traffic, run it in a quieter window or use DisableTransaction() |
| "permission denied" | The database user lacks privileges to create/alter/drop tables | Grant the necessary DDL privileges (CREATE, ALTER, DROP) to the database user |
| "rollback failed" | The Down method contains an error or references objects that don't exist | Review the Down method logic; ensure it reverses the Up method correctly and handles missing objects gracefully |
| "circular dependency" | Seeder A depends on Seeder B, which depends on Seeder A (directly or transitively) | Restructure seeder dependencies to form a directed acyclic graph (DAG) — remove or reorganize the cycle |
| "seeder not found" | Calling runner.Run("Name") with a seeder name that isn't registered | Check the seeder name matches exactly what was passed to reg.Register() |
| "connection pool exhausted" | Too many concurrent operations exceed MaxOpenConns | Increase MaxOpenConns in your configuration or reduce concurrent database operations |
| "SSL certificate error" | PostgreSQL sslmode is set to verify-ca or verify-full but the certificate is missing or invalid | Provide valid SSL certificates or set sslmode: disable for local development |
| "column type not supported" | Using a column type that the current database grammar doesn't support | Check the Database Grammars page for supported types per database engine |
Always test migrations in a staging environment before running them in production. Use m.Status() to review pending migrations and m.Rollback(0) to undo the last batch if something goes wrong.
Best Practices
- Always check errors — never discard the return value from
m.Up(),m.Rollback(), or seeder operations - Use
errors.Is()for sentinel errors — check for specific error types to handle known failure modes gracefully - Use
errors.As()for structured errors — extract the migration name, failing SQL, and underlying cause fromMigrationErrorfor detailed logging - Wrap errors with
%w— preserve the error chain in your migrationUpandDownmethods so callers can inspect the full context - Log before exiting — when a migration fails, log the error details before terminating to aid debugging
What's Next?
- Package Reference — complete method signatures for all go-migration packages
- CLI Reference — run migrations from the command line
- Hooks — execute custom logic before and after migrations