How to Embed an Entire Directory with go

embed

You cannot embed an entire directory as a single blob using `//go:embed`, but you can embed all files within a directory as a `fs.FS` interface by using a wildcard pattern like `dir/*`.

The single binary trap

You are building a CLI tool that ships with default configuration templates. Or maybe a microservice that needs to serve a few static HTML pages without pulling in a separate asset pipeline. You want one binary. You drop a static folder next to your main.go, write //go:embed static/*, and expect Go to hand you a single byte slice containing the whole directory. The compiler disagrees.

Go does not pack directories into a single blob. It builds a virtual filesystem. When you use //go:embed with a wildcard, the compiler scans the matching files, compiles their contents into the binary's read-only data section, and wires up an embed.FS variable. That variable implements the standard fs.FS interface. Think of it like a cabinet with labeled drawers rather than a sealed box. You keep the directory structure intact, and you access individual files by their path at runtime.

How the virtual filesystem works

The embed package bridges compile-time asset packing and runtime file access. Instead of returning a giant []byte, Go gives you a filesystem abstraction. The embed.FS type satisfies the io/fs.FS interface, which means it responds to Open, ReadFile, and Stat calls just like a real disk path would. The difference is that all the data lives in the binary's memory space. No disk I/O happens after the program starts.

The compiler treats the //go:embed comment as a directive, not a regular comment. It must sit immediately above the variable declaration with no blank lines in between. If you insert a newline, the compiler ignores the comment and leaves the variable empty. This strict placement rule prevents accidental misalignment as you refactor code.

Reading a single file

Here is the simplest way to grab a folder of assets and read one file out of it.

package main

import (
	"embed"
	"fmt"
	"os"
)

//go:embed static/*
var staticFS embed.FS // implements fs.FS; holds compiled file data

func main() {
	// paths must include the prefix from the directive
	data, err := staticFS.ReadFile("static/index.html")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to read: %v\n", err)
		os.Exit(1)
	}
	// data is a byte slice copied from the binary's read-only section
	fmt.Printf("read %d bytes from embedded filesystem\n", len(data))
}

The compiler packs the files. You unpack them by path.

Serving and walking the directory

Real applications rarely just read one file. You usually want to serve a whole directory over HTTP or iterate through templates. The standard library already knows how to work with fs.FS. You just need to wrap it correctly.

Here is how you wire the embedded filesystem to the standard library HTTP server.

package main

import (
	"embed"
	"net/http"
)

//go:embed static/*
var staticFS embed.FS // holds the compiled directory tree

func main() {
	// http.FS strips the leading prefix so routes stay clean
	mux := http.NewServeMux()
	mux.Handle("/static/", http.FileServer(http.FS(staticFS)))

	// listenAndServe blocks until the process exits
	http.ListenAndServe(":8080", mux)
}

If you need to inspect what is inside the embedded directory, you walk it just like a real disk path.

package main

import (
	"embed"
	"fmt"
	"io/fs"
)

//go:embed static/*
var staticFS embed.FS // read-only filesystem backed by the binary

func listAssets() {
	// WalkDir traverses the virtual tree without touching the disk
	fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err // abort traversal on permission or path errors
		}
		if !d.IsDir() {
			fmt.Println("embedded:", path)
		}
		return nil // continue to next entry
	})
}

Treat embed.FS like a real disk path. The standard library already knows how to read it.

Path rules and compile-time guards

Path semantics trip up most developers. The path you pass to ReadFile or WalkDir must include the prefix from the directive. If your directive says static/*, you request static/index.html, not index.html. Get it wrong and the runtime returns a file does not exist error. The compiler also enforces strict matching at build time. If your glob pattern matches zero files, go build fails with embed: pattern matches no files. This is a feature. It catches missing assets before they reach production.

The wildcard * only matches files in the immediate directory. It stops at the first slash. If your static folder contains a css subdirectory, static/* leaves it behind. Go 1.20 introduced ** for recursive matching. Use static/** to pull in nested folders. Remember that embedded files are baked into the binary. Changing a template or a stylesheet requires a full rebuild. The binary will never see the updated file on disk.

When you pass an embedded filesystem to a third-party library, the library might expect paths without the static/ prefix. The fs.Sub function solves this. It creates a sub-filesystem rooted at a specific directory, effectively stripping the prefix. You call fs.Sub(staticFS, "static") and hand the result to the library. The library then sees index.html instead of static/index.html.

Test your glob patterns early. A missing asset at runtime is just a compile-time mistake waiting to happen.

When to embed and when to skip it

Use embed.FS with a wildcard when you need to ship a collection of related assets as a single binary. Use a single //go:embed filename directive when you only need one specific file and want to avoid filesystem abstraction overhead. Use fs.Sub when you need to strip the prefix path before passing the filesystem to a library that expects a flat root. Use external file paths when your assets change frequently at runtime and you cannot afford to rebuild the binary. Use embed.FS for configuration defaults, HTML templates, and static web assets that ship with the application. Skip embedding for large media files or datasets that exceed a few megabytes, since they bloat the binary and increase memory pressure.

Trust the compiler's glob validation. Let it fail fast rather than shipping a broken asset pipeline.

Where to go next