On this page

Run migrations at application start-up

Embed the migration files the application was built against, then apply them before accepting traffic that requires their schema. Pass the startup context through to migration execution and return any failure to the startup owner.

What you import

The migration runtime is the migrate area of the sdk kit, with one backend provider for each database:

PackageImport pathWhat it gives you
migrateseagull.sh/seagull/sdk/migrateNewMigrator, the Migrator with Up, Down and Status, the options and hooks, and the typed errors such as ChecksumMismatchError
migrate_backend_*seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite, or the one for your databaseNew, which returns the migrate.Backend for NewMigrator
DatabaseBackend providerConstructor
PostgreSQLmigrate_backend_postgresNew(), or NewPgBouncer() behind a transaction-mode pooler
CockroachDBmigrate_backend_cockroachdbNew()
TimescaleDBmigrate_backend_timescaledbNew()
MySQL and MariaDBmigrate_backend_mysqlNew()
SQLitemigrate_backend_sqliteNew()
DuckDBmigrate_backend_duckdbNew()
ClickHousemigrate_backend_clickhouseNew()

Each New takes the options every backend accepts, such as migrate.WithNamespace, migrate.WithHistoryTable and migrate.WithLock. The migrate API describes each backend. A database none of these covers takes migrate.CustomBackend; see implementing a migration backend.

Embed the migration directory

In a package beside the migration directory, expose an embedded filesystem:

//go:embed migrations/*.sql
var Migrations embed.FS

Import embed in that package. The directory passed to the migrator must match the path inside this filesystem. Generation and migration remain separate operations: sgl generate does not change the running database.

Apply the embedded files before readiness

Open the application's database, choose its backend and construct the migrator. The SQLite example receives a filesystem whose directory is db/migrations:

	migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), migrations, "db/migrations")
	if err != nil {
		return err
	}

Apply the files before running queries that need the new schema:

	applied, err := migrator.Up(ctx)
	if err != nil {
		return fmt.Errorf("migrating: %w", err)
	}
	fmt.Fprintf(output, "start-up: applied %d migrations\n", applied)

Up returns the number applied during this call. An up-to-date database returns zero, but applied files are still checksum-checked. A binary embedding an edited applied migration fails with migrate.ChecksumMismatchError; do not mark the application ready after that failure.

Coordinate concurrent starts

Use the same history names and lock identity in every deployment. With advisory or table locks, history creation happens after acquisition, so concurrent first boots are coordinated. WithLockTimeout bounds acquisition; the startup context bounds the complete call.

LockNone adds no cross-process lock. SQLite serialises writers, DuckDB limits writing processes, and ClickHouse needs external coordination; choose a startup policy suited to the backend rather than assuming every replica can migrate concurrently. See configure locks.

After success, run a representative application query before accepting traffic. Keep database ownership and closure with the application. The runtime reference describes transaction, session and recovery behaviour.

The working example includes connection setup and tests. From the repository root, run:

cd examples/sqlite/027_migrations_at_startup
go test .

Examples for this page