What it does
import "embed"
//go:embed all:web/dist
var webAssets embed.FS
//go:embed config/prompts/*.md
var promptTemplates embed.FS
//go:embed migrations/*.sql
var migrations embed.FS
At compile time, the Go toolchain reads these files into the binary’s data section. At runtime, the binary serves them from memory — no filesystem dependency.
Three places it pays back
1. UI assets
A typical “Go API + React SPA” deploy has two artefacts: the Go binary and the SPA bundle. They get deployed separately; configured separately; CORS-coordinated.
With embed.FS, the SPA is part of the binary:
//go:embed all:web/dist
var webFS embed.FS
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", apiHandler)
mux.Handle("/", http.FileServer(http.FS(webFS)))
http.ListenAndServe(":8080", mux)
}
One artefact. One Docker image. Zero CORS configuration. No “did we deploy the right SPA version” question.
For Genie’s UI, the React build lands in pkg/web/handlers/ui/. The handler embeds and serves. The on-call doesn’t think about it.
2. Prompt templates
Prompts are versioned content. They should be in the repo, reviewed in PRs, deployed as a unit with the code that uses them.
//go:embed prompts/*.tmpl
var promptFS embed.FS
func loadPrompt(name string) (*template.Template, error) {
data, err := promptFS.ReadFile("prompts/" + name + ".tmpl")
if err != nil { return nil, err }
return template.New(name).Parse(string(data))
}
The prompts are in version control next to the code that uses them. A prompt change is a PR. A prompt regression is reverted by reverting the PR. No external prompt-storage system to manage.
3. SQL migrations
The classic Go migration pattern:
//go:embed migrations/*.sql
var migrationFS embed.FS
func runMigrations(ctx context.Context, db *sql.DB) error {
entries, _ := fs.ReadDir(migrationFS, "migrations")
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sql") { continue }
sql, _ := fs.ReadFile(migrationFS, "migrations/"+e.Name())
if _, err := db.ExecContext(ctx, string(sql)); err != nil {
return fmt.Errorf("migration %s: %w", e.Name(), err)
}
}
return nil
}
Migrations are part of the binary. The deploy process doesn’t need to ship a separate migration script. Schema drifts between binary version and migration version are impossible.
When NOT to use it
- Files that need to be edited after deploy. Config that an operator changes via a control plane belongs on a filesystem, not embedded.
- Very large files. Embedding a 500 MB ML model into the binary makes the binary huge and slow to load. Mount it; don’t embed it.
- User-uploaded content. Obviously.
The deployment shape it enables
For Genie:
- One Docker image, ~30 MB.
- One binary, ~25 MB.
- Health check on
/readyzconfirms binary loaded. - No external dependencies for static assets, prompts, or migrations.
This is the “single binary” appeal of Go made concrete. The deployment story collapses to one artefact; the operational surface area shrinks accordingly.
For any Go service: audit your deploy. Are you shipping multiple artefacts that always travel together? If yes, embed.FS is probably the right tool.