12 - Embedding with go:embed
📋 Jump to TakeawaysGo compiles to a single binary. No runtime, no VM, no node_modules. But what about your templates, static files, config? You have two options:
Copy them alongside the binary. Simple, and you can edit templates without recompiling. This is what most people do, and it works fine — especially with Docker where you control the file layout.
Embed them in the binary with
//go:embed. One file to deploy, nothing can be missing at runtime. The tradeoff is a larger binary and you need to recompile to change a template.
Embedding makes the most sense for small to medium projects where deployment simplicity matters more than hot editing files. For larger projects with many assets, copying files is often more practical. Either way, it's worth knowing how //go:embed works — it's useful beyond just templates.
The Directive
//go:embed is a compiler directive. It tells the Go toolchain to include file contents in the binary at compile time:
package main
import _ "embed"
//go:embed version.txt
var version stringThat's it. The contents of version.txt end up in the version variable. No file I/O at runtime. The file doesn't even need to exist on the server.
A common use case: CLI version checking. Generate version.txt from git during your build, embed it, and at runtime compare it against the latest version from a remote endpoint. If the user is behind, tell them to update. The version is baked in at compile time — no config files, no flags.
Rules:
- The comment must be exactly
//go:embedwith no space between//andgo - The variable must be at package level, not inside a function
- You need to import
embed(or_ "embed"if you only use the directive)
Embedding a Single File
For strings and byte slices:
import _ "embed"
//go:embed schema.sql
var schema string
//go:embed logo.png
var logo []byteStrings work for text files. Byte slices work for everything. The compiler figures out the type from the variable declaration.
Embedding Directories with embed.FS
For multiple files, use embed.FS:
import "embed"
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static/*
var staticFS embed.FSembed.FS implements fs.FS, the standard filesystem interface. This means it works everywhere the stdlib expects a filesystem: template.ParseFS, http.FileServer, fs.WalkDir.
Glob patterns — wildcards for matching file paths — work here:
templates/*.htmlmatches all HTML files in templates/static/*matches everything in static/ (one level)all:staticmatches everything including files starting with.or_
Embedding Templates
In lesson 11, we parsed templates from disk. Now we embed them:
//go:embed templates/*.html
var templateFS embed.FS
func parseTemplates() *template.Template {
funcMap := template.FuncMap{
"formatTime": func(t time.Time) string {
return t.Format("Jan 02, 2006")
},
}
return template.Must(
template.New("layout.html").Funcs(funcMap).ParseFS(
templateFS, "templates/*.html",
),
)
}ParseFS works like ParseFiles but reads from the embedded filesystem. Same templates, same output. But now they're inside the binary.
Serving Static Files
Embed your CSS, JS, and images, then serve them with http.FileServer:
//go:embed static/*
var staticFS embed.FS
func main() {
mux := http.NewServeMux()
// Serve static files at /static/
mux.Handle("GET /static/", http.FileServerFS(staticFS))
// ...
}http.FileServerFS takes any fs.FS and serves it over HTTP. Request for /static/style.css reads from the embedded static/style.css. No disk access.
If you need to strip a path prefix (serve static/style.css at /style.css), use fs.Sub:
sub, _ := fs.Sub(staticFS, "static")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServerFS(sub)))Embedding Config Files
Embed default configuration that ships with the binary:
//go:embed defaults.json
var defaultConfig string
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
// Fall back to embedded defaults
data = []byte(defaultConfig)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}We embed as string since JSON is text. You'll also see []byte — both work, but string makes it clear the content is human-readable. json.Unmarshal needs []byte, so we convert when using the fallback.
For a simple API with a few config values, hardcoded Go defaults (like we did in lesson 02) are simpler. Embedding a defaults file makes more sense when the config is complex or you want to ship a reference config that users can extract and customize.
The catch: don't embed environment-specific config (database URLs, API keys, secrets). Those change per environment and should come from environment variables, not compiled-in files. Embedding is only for defaults that are safe to ship in the binary.
Embedding SQL Migrations
This pairs well with the database lesson. Embed your schema:
//go:embed migrations/*.sql
var migrationsFS embed.FS
func migrate(db *sql.DB) error {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return err
}
for _, e := range entries {
data, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
return err
}
if _, err := db.Exec(string(data)); err != nil {
return fmt.Errorf("migrate %s: %w", e.Name(), err)
}
}
return nil
}What You Can't Embed
- Files outside the module directory. The compiler won't reach outside
go.modboundaries - Files matched by
.gitignoreare still embedded. The compiler doesn't read.gitignore - Symlinks are followed, but the embed path is the original path
- You can't embed files dynamically. The paths are fixed at compile time
Applying to Our Project
Add embedded templates and static files to the bookmarks API:
bookmarks/
├── api/
│ ├── main.go
│ ├── handler.go
│ ├── handler_html.go
│ ├── embed.go ← embed declarations
│ ├── store.go
│ ├── templates/
│ │ ├── layout.html
│ │ └── list.html
│ └── static/
│ └── style.css
└── pkg/
└── ...The embed.go file keeps all embed directives in one place:
// embed.go
package main
import "embed"
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static/*
var staticFS embed.FSUpdate main.go to use them:
func main() {
tmpl := template.Must(
template.New("layout.html").Funcs(funcMap).ParseFS(
templateFS, "templates/*.html",
),
)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(staticFS))
mux.HandleFunc("GET /", handleBookmarksList(tmpl, store))
// ... API routes
}Build it, copy the binary anywhere, run it. Templates and CSS are inside. That's the win.
Key Takeaways
//go:embedbakes files into the binary at compile time. No runtime file I/O needed- Use
stringor[]bytefor single files,embed.FSfor directories embed.FSimplementsfs.FS, so it works withtemplate.ParseFS,http.FileServerFS, andfs.WalkDir- Embed templates, static assets, SQL migrations, default configs. Anything that ships with the app
- The big win is single-binary deployment. Build once, copy anywhere, run
- Paths are relative to the source file containing the directive. No absolute paths