How to Use embed.FS for Embedded File Systems

Use the //go:embed directive with embed.FS to compile files directly into your Go binary for runtime access.

The single-binary problem

You built a CLI tool that generates reports using HTML templates. It works perfectly on your machine. You send the binary to a teammate. They run it and get a panic: the template file is missing. You forgot to ship the templates/ directory alongside the executable. Now you're zipping assets, writing install scripts, or begging users to download a separate archive.

Go offers a cleaner path. You can bake the files directly into the binary. The result is a single executable that carries its own data. No zip files. No missing assets. No deployment scripts to copy directories.

What embed.FS does

The embed package lets you embed files into your Go binary at compile time. The embed.FS type represents a virtual file system containing those files. At runtime, the files are accessible through the standard io/fs interface, just like they would be on a disk.

Think of it like a Swiss Army knife. The knife is your code. The blades and screwdriver are your files. Instead of carrying a separate toolbox, everything is molded into the handle. When you run the program, the files are there, accessible via the same methods you use for disk files.

The embed.FS type implements io/fs.FS. This is the key design choice. It means embed.FS works with any function that accepts a file system. You can pass it to http.FileServer, fs.Glob, fs.WalkDir, or third-party libraries that support fs.FS. You write your logic once against the interface, and it works with embedded files in production or disk files during development.

Minimal example

Here's the simplest way to embed a file. You declare a variable, add a directive, and the compiler does the rest.

package main

import (
	"embed"
	"fmt"
)

//go:embed hello.txt
// content holds the embedded file system containing hello.txt.
var content embed.FS

func main() {
	// ReadFile works on embed.FS just like os.ReadFile works on disk.
	data, err := content.ReadFile("hello.txt")
	if err != nil {
		// Panic is acceptable in a minimal example to show the error path.
		panic(err)
	}
	// Convert bytes to string for display.
	fmt.Print(string(data))
}

The //go:embed directive must sit directly above the variable declaration. The path pattern is relative to the source file. If hello.txt is in the same directory as main.go, the pattern hello.txt matches it.

Go embraces verbose error checking. The if err != nil block is boilerplate by design. It forces you to acknowledge the failure case. In production code, return the error up the stack rather than panicking.

How the compiler works

The magic happens during compilation. The //go:embed directive is a special comment recognized by the go toolchain. When you run go build, the compiler scans for these directives. It reads the files matching the pattern, converts them to byte slices, and injects them into the binary as constants.

At runtime, the embed.FS variable acts as a virtual file system. It doesn't touch the disk. It serves the data from memory where the binary loaded it. This means reading embedded files is fast and doesn't depend on file permissions or disk availability.

The directive supports glob patterns. You can embed a single file with file.txt, a directory with dir/*, or recursive matches with dir/**. The compiler resolves the patterns and includes all matching files. If a pattern matches no files, the build fails. This is a safety feature. You won't ship a binary with missing assets.

Serving files with http.FileServer

A common use case is serving static files from a web server. You can embed a directory of HTML, CSS, and JS files and serve them via http.FileServer. The tricky part is path handling. If you embed static/*, the paths inside the file system start with static/. You need to strip that prefix so requests map correctly.

Here's how to serve embedded static files with proper path stripping.

package main

import (
	"embed"
	"io/fs"
	"log"
	"net/http"
)

//go:embed static/*
// staticFiles contains all files in the static directory.
var staticFiles embed.FS

func main() {
	// Strip the "static/" prefix so requests map to the root of the embedded FS.
	sub, err := fs.Sub(staticFiles, "static")
	if err != nil {
		log.Fatal(err)
	}

	// Create a file server handler using the subdirectory.
	handler := http.FileServer(http.FS(sub))

	// Serve on localhost:8080.
	log.Println("Serving on :8080")
	log.Fatal(http.ListenAndServe(":8080", handler))
}

The fs.Sub function returns a new fs.FS that represents a subdirectory. It strips the prefix from paths. This is safer than manual path manipulation because it prevents directory traversal attacks. fs.Sub ensures that paths cannot escape the embedded directory.

Public names start with a capital letter. If you export the embedded variable, other packages can access it. Keep variables lowercase unless you need to share the file system across packages. In this example, staticFiles is lowercase because it's only used within the main package.

The io/fs interface connection

The embed.FS type isn't just a bag of bytes. It implements the io/fs.FS interface. This interface defines a standard way to access files. Because embed.FS satisfies fs.FS, you can pass it to any function that accepts a file system.

This promotes testability and abstraction. Write your functions to accept fs.FS. In production, pass embed.FS. In tests, pass a mock file system or use os.DirFS to point to a temporary directory.

Accept interfaces, return structs. This is the most common Go style mantra. Functions should accept fs.FS, not embed.FS. This allows callers to swap in different file systems for testing or runtime configuration.

Here's a function that processes assets using the interface.

import (
	"io/fs"
)

// ProcessAssets accepts any fs.FS implementation, enabling testing with mock file systems.
func ProcessAssets(system fs.FS) error {
	// List files matching the pattern using the standard library.
	matches, err := fs.Glob(system, "*.txt")
	if err != nil {
		return err
	}
	// Iterate over matches and read each file.
	for _, name := range matches {
		data, err := fs.ReadFile(system, name)
		if err != nil {
			return err
		}
		// Process data here.
		_ = data
	}
	return nil
}

The _ (underscore) discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. In this example, _ = data suppresses the unused variable warning while demonstrating the loop structure.

Embedding strings and byte slices

Sometimes you don't need a file system. You just need the raw content. The embed package supports embedding directly into string or []byte variables. This avoids the overhead of the file system abstraction for simple use cases.

package main

import "fmt"

//go:embed template.html
// templateHTML holds the raw HTML content as a string.
var templateHTML string

func main() {
	// Use the string directly without reading from a file system.
	fmt.Println(templateHTML)
}

Embedding into a string is useful for templates, SQL queries, or configuration defaults. The variable contains the file content as a Go string. You can pass it to html/template or other libraries directly.

Pitfalls and compiler errors

Embedding files introduces specific failure modes. The most common error occurs when the pattern matches no files. If your pattern matches nothing, the compiler rejects the build with //go:embed: pattern matches no files: static/*. This is a compile-time error, which is a safety feature. You won't ship a binary with missing assets.

Paths are relative to the source file, not the package directory. If your source is cmd/server/main.go and you embed ../static/*, the paths inside the file system will start with static/. You cannot embed files outside the module directory in some toolchains, but relative paths with .. generally work. Test your patterns carefully.

The //go:embed directive must be immediately before the variable. If you put the directive on the wrong line, the compiler complains with //go:embed: no variable declaration found. The directive must sit directly above the var statement with no blank lines. Run gofmt to ensure consistent formatting. The tool will not remove the directive, but it keeps the code style uniform.

embed.FS is immutable. You can't write back to it. If your application needs to modify files, you must read the embedded content, modify it in memory, and write to a temporary file or return the modified data. Embedding is for read-only assets.

There is no hard limit on the size of embedded files, but practical limits apply. Embedding a 500MB database will make your binary huge and slow to load. The binary size increases by the size of the files. Loading time increases because the OS must map the larger binary into memory. Keep embedded assets under a few megabytes for best performance.

Decision matrix

Use embed.FS when you need a single binary deployment and the assets are small enough to fit in memory.

Use embed.FS when you want compile-time safety to ensure assets exist before the binary is built.

Use embed.FS when you are building a CLI tool that relies on templates or default configurations bundled inside.

Use string or []byte embedding when you need a single file's content and want to avoid the io/fs overhead.

Reach for external files when the data changes frequently without recompiling, such as user-generated content or large media files.

Reach for external files when the asset size exceeds hundreds of megabytes, as embedding bloats the binary and increases load time.

Use os.ReadFile for configuration files that users need to edit at runtime without rebuilding the application.

Embedding is a compile-time decision. The files become part of the code. Choose embedding for portability and safety. Choose external files for mutability and size.

Where to go next