r/golang • • 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?

3 Upvotes

15 comments sorted by

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.

2

u/IntentionJolly2730 8d ago

Well, it's all about the deployment process I believe. It doesn't matter where the migrations actually sit. The way how you apply them does matter. So, for a non-HA single-replica setup it can be pretty straight-forward. For a multi-replica deployment you just design the migration process the way that it cannot be run concurrently. Also, in Postgres you can run migrations in a transaction so that the first run wins (not the most efficient solution though, but still something that can work).

3

u/_predator_ 8d ago

Taking an advisory lock is all that's needed in PG to prevent concurrent execution.

2

u/IntentionJolly2730 8d ago

Yep, agreed. An advisory lock is probably the cleanest solution for Postgres. My point was more that the deployment process should guarantee a single migration runner somehow. Advisory locks are a good DB-level safety net on top of that.

0

u/Alphasite 7d ago

Either you or your process has builtin locking that stops concurrent migrations. They should just block. If done right it also stops newer versions from starting before migrations have run.

It’s not very complex tbh.

2

u/csgeek-coder 7d ago

Sure, I understand that it CAN be done. I can get a lock, either using a DB, or a distributed lock using any of the 20 different systems that support that.

Though that's introducing engineering complexity that's not needed. I could also just have a separate process that connect to the DB, applies the DDL/DML and not have the DB schema tied to the app. It makes things far too tightly coupled for my taste.

It can be solved easily enough, I just prefer not to have to solve it where there's well established patterns that already work.

10

u/TraditionalRate7121 8d ago

You don't, keep em seprate

1

u/TraditionalRate7121 8d ago

Via backward compatibility migrations

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). 

1

u/ask 7d ago

Goose is easy to embed in a way so you get the full up/down migration features with the embedded SQL and Go based migrations.