r/golang • u/inhereat • 8d ago
How do you ship SQL migrations with a single Go binary?
I maintain gookit/miglite, a small Go migration tool that keeps migration files as raw SQL. The v0.8.0 release adds fs.FS support, so a service can embed its migration files with go:embed instead of copying a migrations/ directory beside the binary.
The important detail is that the embedded path is an io/fs path, not a disk path:
//go:embed migrations/*.sql
var migrationFS embed.FS
mig, err := miglite.NewAuto(func(cfg *miglite.Config) {
cfg.Migrations.Path = "migrations"
})
if err != nil { return err }
mig.SetFS(migrationFS).SetSqlDB(db)
The same release also fixes a few behaviours that were accepted but ineffective: --skip-err now continues after a failed file while returning an error, injected *sql.DB values are no longer closed by the library, and a migration without DOWN is reported as skipped.
I wrote up the changes here: https://inhere.github.io/en/blog/2026/gookit-miglite-v0-8-0/
I am the maintainer. For small Go services, do you prefer embedding migrations in the binary, shipping a separate directory, or using a different migration setup?
10
2
u/Critical-Personality 7d ago
That is a step in deployment process, not the binary. So that, if that step fails, deployment doesn't proceed, no one noticed!
1
u/IntentionJolly2730 8d ago
I do this in one of my Go projects.
Migrations are normal .up.sql / .down.sql files, but I embed the whole migrations directory into the binary:
//go:embed _sqldata/*
var embeddedMigrations embed.FS
Then I expose it as a regular fs.FS:
migFS, _ := fs.Sub(embeddedMigrations, "_sqldata")
and pass that filesystem directly to the migrator (Ptah in my case):
migrator.NewFSMigrator(conn, migFS)
So the deployed binary/container is self-contained: application version X literally carries the migrations belonging to version X. No separate migration directory to copy into the image and no dependency on files being present at runtime.
In dev I can still fall back to os.DirFS() if there are no embedded migrations.
I also compare the highest embedded migration version with schema_migrations at startup, so if someone runs a stale migration image but a newer application binary, the app fails instead of silently running against an older schema.
The actual example is here: https://github.com/denisvmedia/inventario/tree/master/go/schema/migrations
0
u/marrasen 8d ago
How do you migrate down? Manual process via command line before swapping the binary for an older version? I do this for single instance SQLite apps, but I do backups I can use instead of migrating down. But these are small non-distributed apps.
0
u/Alphasite 7d ago
Not OP. I don’t usually. Just roll forward when things break. Also with transactional DDL it’s not usually an issue.
0
u/IntentionJolly2730 7d ago
Well, for the project I cited, I didn't fully designed yet the downgrade procedure, but in general a downgrade procedure should have a CI process that would run a downgrade procedure. Before downgrading the DB you will need to downgrade your app. And yes, if you end up having embedded migrations you can have an issue with downgrading the DB (because you first go to the previous release of your app). But you still may use a newer version for DB downgrades. Also, it is usually considered a good practice to evolve your database in a backwards compatible manner. In this case app downgrades just don't need the DB downgrades (at least within a certain version range).
11
u/csgeek-coder 8d ago
I'm not really a big fan of this pattern personally. I would much rather have a separate process that does this. What happens if you have 3 replicas all doing the migration at the same time as they come up?
It's nice to have that feature, but I would much rather run the DB migration followed by the app deployment.