embed for SQL Migrations & Seed Data
Go 1.16+ embed lets you ship SQL migration files and seed scripts inside the compiled binary.
Deploy artifacts stay self-contained: the same container image that runs the API can apply schema versions without mounting a host directory.
Summary
//go:embed attaches files or directories to variables of type embed.FS.
Migration libraries accept io/fs or http.FileSystem adapters over the embedded FS.
Seed data for dev and test can ship in a separate embedded tree from production migrations.
Recipe
Quick-reference recipe card - copy-paste ready.
package migrations
import (
"embed"
"io/fs"
)
//go:embed sql/*.sql
var files embed.FS
func FS() fs.FS {
sub, _ := fs.Sub(files, "sql")
return sub
}import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
source, _ := iofs.New(migrations.FS(), ".")
m, _ := migrate.NewWithSourceInstance("iofs", source, dbURL)
_ = m.Up()When to reach for this:
- Single-binary deploys (Kubernetes, serverless containers) without ConfigMap-mounted SQL.
- CLI tools that must migrate offline laptops without a git checkout.
- Integration tests that need known schema embedded next to test code.
Working Example
package main
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func main() {
db, _ := sql.Open("sqlite3", ":memory:")
defer db.Close()
goose.SetBaseFS(migrationFiles)
goose.SetDialect("sqlite3")
if err := goose.Up(db, "migrations"); err != nil {
panic(err)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table'`).Scan(&n)
fmt.Println("tables:", n)
}migrations/00001_init.sql:
-- +goose Up
CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT);
-- +goose Down
DROP TABLE widgets;What this demonstrates:
go:embedglobs SQL next to the Go source file.goose.SetBaseFSreads migrations from memory instead of disk.- Same pattern works for golang-migrate
iofssource driver. - In-memory SQLite proves the flow without external files at runtime.
Deep Dive
How It Works
- The compiler records matched files into the binary at build time.
embed.FSis read-only and safe to share across goroutines.fs.Subnarrows the root when SQL lives in a subdirectory.- Changing SQL requires rebuild and redeploy - which is desirable for reproducible releases.
Layout Conventions
| Path | Purpose |
|---|---|
migrations/*.sql | Versioned up/down schema |
seed/dev/*.sql | Local fixtures (optional separate embed) |
testdata/schema.sql | Integration test bootstrap |
internal/migrations | Package exporting FS() for main |
golang-migrate with iofs
d, err := iofs.New(migrations.FS(), ".")
m, err := migrate.NewWithInstance("iofs", d, "postgres", dbURL)- Source name
"iofs"pairs with database driver name inNewWithInstance. - Locking still uses the database; embed only replaces filesystem reads.
- CI should run the same embedded FS tests as production.
Seed Data Patterns
- Dev-only seeds: build tag or separate
//go:embed seed/*imported only frommain_dev.go. - Idempotent seeds:
INSERT ... ON CONFLICT DO NOTHINGor goose Go migration with loops. - Never embed production secrets - seeds contain fake data only.
Gotchas
- Embed path wrong after refactor - Build fails with "pattern matches no files". Fix: keep SQL directory adjacent to the Go file carrying
go:embed. - Editing migrations without rebuild - Running old binary applies stale schema. Fix: tie image tag to git SHA; migration version table detects drift.
- Huge seed files bloat binary - Container size and link time grow. Fix: compress dev seeds or load from object storage in non-prod only.
- Writable embed.FS assumption - Embed is read-only; tools that rewrite files need disk or temp copies. Fix: use library APIs that read from
fs.FS. - Mixing host and embed sources - Different behavior in dev vs prod. Fix: one source of truth; local dev uses the same embed or
go runfrom module root. - Secrets in SQL files - Credentials committed in seed scripts. Fix: parameterize DSN from environment; SQL contains no passwords.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| ConfigMap volume (K8s) | Ops wants hot-patch SQL without rebuild | You need immutable release artifacts |
| Git checkout in container | Simple Dockerfile COPY migrations | Distroless images without shell |
| Remote migration service | Central schema registry | Small teams want embedded simplicity |
go:generate SQL bundling | Custom codegen pipeline | Std embed is enough |
| Flyway on JVM sidecar | Enterprise standard | Pure Go deploy unit |
FAQs
Does embed work with go test?
Yes - tests import the same package and call goose.Up against ephemeral databases.
Keep migration files in the package under test.
Can I embed only some files?
Use separate variables and //go:embed directives per directory.
all: prefix embeds hidden files if needed.
How do I exclude dev seeds from prod binary?
Build tags on files (//go:build dev) or separate commands/packages.
Prod main imports only production migration package.
What about subdirectories?
//go:embed migrations embeds the tree; use fs.Sub to set goose directory root.
Does TinyGo support embed?
Verify board targets at build; WASM and embedded builds may have size limits.
Test migration size on constrained targets.
How do I validate embed at CI?
go test that runs Up and asserts tables exist.
Fail build if pattern is empty.
Can goose Go migrations embed helpers?
Go migration files compile into the binary naturally; SQL uses embed.FS.
Pick one style per repo for review clarity.
How does this interact with golang-migrate CLI?
CLI on laptop can still use -path ./migrations while production binary uses iofs - keep files identical in git.
Are embedded migrations reversible?
Same down files as disk workflow; embed only changes transport.
Rollback discipline unchanged.
What file size limits exist?
Practical limit is binary size and memory, not Go syntax.
Split large data migrations into batched Go migrations.
Related
- Migrations with golang-migrate & goose - tool commands and CI
- Databases Basics - executing SQL
- Databases & Data Access Best Practices - immutable migration artifacts
- Data Access in Go: database/sql as the Foundation - pool wiring after migrate
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).