12 - Embedding with go:embed

📋 Jump to Takeaways

Go compiles to a single binary. No runtime, no VM, no node_modules. But what about your templates, static files, config? You have two options:

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

  2. 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 string

That'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:embed with no space between // and go
  • 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 []byte

Strings 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.FS

embed.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/*.html matches all HTML files in templates/
  • static/* matches everything in static/ (one level)
  • all:static matches 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.mod boundaries
  • Files matched by .gitignore are 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.FS

Update 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:embed bakes files into the binary at compile time. No runtime file I/O needed
  • Use string or []byte for single files, embed.FS for directories
  • embed.FS implements fs.FS, so it works with template.ParseFS, http.FileServerFS, and fs.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

🚀 Ready to run?

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy