Embedding files in Go binaries: best practices explained. Ship assets inside the binary.
Why embed files in your Go binary?
One of the most underrated features in modern Go is the ability to embed files directly into your compiled binary. Before Go 1.16, deploying a web service meant shipping your application alongside configuration files, SQL migrations, HTML templates, and static assets—each one a potential point of failure. Embedding files in Go binaries is a game-changer: it lets you create a single, self-contained executable that carries everything needed to run. No more worrying about missing files in production, no more deployment scripts that sync assets to servers, and no more debugging issues where templates work locally but not in the cloud.
In this article, we'll explore how to use the //go:embed directive effectively, demonstrate embedding templates and migrations, show how to serve static files over HTTP, and walk through the deployment benefits of shipping a single binary.
Understanding the //go:embed directive
The //go:embed directive tells the Go compiler to include files or directories in your binary at compile time. It's a compile-time operation, not a runtime lookup—the files become part of the executable itself.
To use it, you declare a variable at package level and annotate it with //go:embed. The variable must be of type string, []byte, or embed.FS.
package main
import (
"embed"
)
//go:embed version.txt
var version string
//go:embed config.json
var configData []byte
//go:embed templates/*
var templatesFS embed.FS
func main() {
println("Version:", version)
}The path in the //go:embed comment is relative to the source file containing the directive. You can embed single files or entire directories using glob patterns like templates/* or migrations/*.sql. When you compile the binary, those files are baked in. No external files needed at runtime.
Important constraints: Only files that exist at compile time are embedded. You can't add files dynamically after building. Also, Go only embeds files, not empty directories. If you need to preserve directory structure with empty folders, you must include at least one file in each directory.
Embedding templates for web services
Web applications often ship with HTML templates. Embedding them means you get one binary that serves dynamic content without external template files. This is especially powerful with Go's html/template or third-party template engines.
Consider an e-commerce platform that needs to render order confirmation emails and HTML pages. Instead of keeping templates in a separate folder that could be forgotten during deployment, embed them:
package main
import (
"embed"
"html/template"
"net/http"
)
//go:embed templates/*.html
var templatesFS embed.FS
func main() {
// Parse all templates from the embedded filesystem
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
panic(err)
}
http.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"OrderID": "ORD-12345",
"Email": "customer@example.com",
}
tmpl.ExecuteTemplate(w, "order.html", data)
})
http.ListenAndServe(":8080", nil)
}The template.ParseFS function reads templates from the embed.FS and compiles them just like reading from disk. At runtime, they're already in memory as part of the binary. No file I/O, no missing templates, no permission issues.
For more complex scenarios—think a SaaS dashboard with dozens of templates—you can organize them in subdirectories and parse selectively:
//go:embed templates/admin/* templates/public/*
var templatesFS embed.FS
// Parse only admin templates
adminTmpls, _ := template.ParseFS(templatesFS, "templates/admin/*.html")
// Parse only public templates
publicTmpls, _ := template.ParseFS(templatesFS, "templates/public/*.html")Embedding database migrations
Database migrations are a perfect candidate for embedding. They're static SQL files that define your schema, and they need to stay in sync with your application version. By embedding migrations, you ensure every deployed binary has exactly the right migration set for that version.
Here's a practical example for a ride-hailing platform managing its PostgreSQL schema:
package main
import (
"embed"
"fmt"
"io/fs"
"sort"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func runMigrations() error {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return err
}
// Sort migration files by name (001_, 002_, etc.)
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() {
continue
}
content, err := fs.ReadFile(migrationsFS, fmt.Sprintf("migrations/%s", entry.Name()))
if err != nil {
return err
}
// Execute the SQL content against your database
fmt.Printf("Running migration: %s\n", entry.Name())
// db.Exec(string(content))
}
return nil
}When your application starts, it can check for pending migrations and apply them automatically. Everything is versioned together: the binary includes the exact migrations it needs. This eliminates the coordination problem where you deploy code but forget to run migrations on the database.
Serving static files with embed.FS and http.FileServer
The embed.FS type implements fs.FS, which means it works seamlessly with Go's standard HTTP routing. You can serve static assets—CSS, JavaScript, images, fonts—directly from the binary without setting up external CDNs or worrying about asset synchronization.
package main
import (
"embed"
"net/http"
)
//go:embed static/*
var staticFS embed.FS
func main() {
// Serve static files from the embedded filesystem
http.Handle("/assets/", http.FileServer(http.FS(staticFS)))
// Or with a Sub() for cleaner paths
assetHandler := http.FileServer(http.FS(staticFS))
http.Handle("/", assetHandler)
http.ListenAndServe(":8080", nil)
}The http.FS() wrapper converts embed.FS into an http.FileSystem, and then http.FileServer handles the serving logic. Requests for /assets/style.css automatically load the file from the embedded filesystem. You can also use Sub() to serve a subdirectory:
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
http.Handle("/assets/", http.FileServer(http.FS(staticSubFS)))This approach is ideal for single-page applications, admin dashboards, and simple static sites. For high-traffic scenarios with massive assets, you'd still use a CDN, but for serving your application's UI and core assets, embedding is reliable and simple.
One binary to deploy
The ultimate benefit of embedding files is deployment simplicity. Instead of coordinating multiple artifacts—the binary, config files, SQL scripts, templates—you get a single executable. This changes your deployment pipeline significantly.
Consider a microservice in a trading platform. With embedded assets, your deployment becomes:
// Before: copy binary, copy templates, copy migrations, copy config
// Now: just copy the binary
// Binary startup:
// 1. Parse embedded config (if small)
// 2. Apply embedded migrations
// 3. Serve embedded templates and static files
// 4. Ready to handle requestsIn Kubernetes, you can build the binary in a multi-stage Docker build, and the final image contains only the executable. No volume mounts needed for asset files. In serverless environments like AWS Lambda, shipping a single binary eliminates deployment package complexity. For traditional servers, you deploy via SSH or your provisioning tool—one file instead of five.
Versioning also becomes automatic. You never deploy a binary with version 2.0 code but version 1.9 templates. Everything is locked together at compile time.
Common mistakes
- Forgetting the import: You must import the
embedpackage even if you don't use it directly. The//go:embeddirective requires it. Without the import, the directive is ignored and a warning is printed. - Incorrect paths: Paths in
//go:embedare relative to the source file, not the project root. If your migrations are indb/migrations/and your main.go is in the root, use//go:embed db/migrations/*.sql, not just//go:embed migrations/*.sql. - Mixing embed with build constraints: Be careful with build tags. If you use
// +buildconstraints, embedded files might not be included as expected. Always test with the exact build command you use in production. - Large binaries: Embedding gigabytes of data bloats your binary. Keep embedded assets reasonable. For large static files or media, use external storage.
- Forgetting embed.FS for multiple files: If you embed a single file as a string, you get
string. If you embed a directory or multiple files, useembed.FS. Mixing these up causes type errors at compile time.
Key takeaways
- Use
//go:embedto include templates, migrations, configuration, and static files in your binary at compile time. - Embed templates with
template.ParseFS()for reliable rendering without external files. - Embed database migrations to ensure schema synchronization with your application version.
- Serve static assets using
http.FileServer(http.FS(embedFS))for a complete web application in one binary. - Embedding simplifies deployment: one artifact, no missing files, no permission issues, automatic versioning.
- Remember paths are relative to the source file, not the project root.
- Test your embedded assets before shipping to production; embedding happens at compile time.






Comments
Loading comments…