The embed package is small, almost ceremonial, but the one type it exports, embed.FS, is what makes embedded files plug into the rest of the standard library. Once you understand that an embed.FS is just an fs.FS with extra methods, the whole ecosystem of file-consuming APIs (http.FileServer, template.ParseFS, fs.Sub, fs.WalkDir) opens up.
The embed package's public API is one line long. It exports a single type, embed.FS, and nothing else. There are no constructors, no helper functions, no constants. You never call embed.NewFS() or embed.Open() directly. Instead, the compiler builds an embed.FS value for you when it sees a //go:embed directive above a variable of that type.
Assume catalog.json is a small product seed file like [{"id":1,"name":"Wireless Mouse","price":29.99}].
The variable catalogFS is a value of type embed.FS. It looks like a struct, but its internal fields are unexported and the only way to populate it is through the compiler. At runtime the type exposes three methods: Open, ReadFile, and ReadDir. Those three methods are exactly the methods of three interfaces from the io/fs package. That is the whole point of the type.
| Method | Signature | Purpose |
|---|---|---|
Open(name string) (fs.File, error) | Implements fs.FS | Open one file for reading |
ReadFile(name string) ([]byte, error) | Implements fs.ReadFileFS | Read one file fully into memory |
ReadDir(name string) ([]fs.DirEntry, error) | Implements fs.ReadDirFS | List entries in a directory |
Because embed.FS satisfies fs.FS, you can hand it to anything that takes an fs.FS: the HTTP file server, the template parser, the test helpers, fs.WalkDir, fs.Sub, fs.Glob. The embed package itself does almost nothing. The leverage comes from fs.FS being a widely adopted interface and embed.FS slotting cleanly into it.
The target variable for an embed directive can be string, []byte, or embed.FS. The first two hold the contents of one file each. embed.FS is different. It holds a virtual filesystem with paths, directories, and any number of files.
The choice between the three is mostly about how many files and how you'll use them. A single piece of text that gets concatenated into one place wants string. A single binary blob that gets handed to a parser wants []byte. Anything with multiple files, anything that's going to be walked by a directory traversal, or anything that needs to plug into http.FileServer or template.ParseFS wants embed.FS.
Assume terms.txt holds a short legal blurb, templates/receipt.html holds an HTML template, and migrations/ holds two SQL files: 001_init.sql and 002_add_index.sql.
Three different shapes, three different access patterns. The string is ready to print or interpolate. The byte slice is ready to hand to an HTML template parser that wants raw bytes. The embed.FS exposes a directory you can list and walk.
Cost: every embedded byte ends up in the compiled binary's data section. A 50 MB static asset folder produces a binary that is at least 50 MB larger. We'll come back to binary size at the end of the chapter.
A small note on imports. With both blank _ "embed" (for string and []byte) and direct "embed" (for embed.FS) in the same file, Go will keep both because the compiler treats them as distinct uses. In practice most files use only one form.
The patterns after //go:embed are glob expressions resolved against the directory of the source file containing the directive. Here's the cheat sheet you'll actually need in practice.
| Pattern | Matches | Notes |
|---|---|---|
file.txt | One file by name | Most common form for string/[]byte |
dir/file.txt | One file in a subdirectory | Forward slash, even on Windows |
dir | Entire directory tree (recursive) | Excludes dot-files and underscore-files |
dir/* | Direct children only | One level, no recursion |
dir/*.html | Files matching pattern in dir | Glob, not regex |
templates static migrations | Multiple patterns | Space-separated |
all:dir | Entire tree including hidden files | Go 1.18+ |
all:dir/* | Direct children including hidden | Go 1.18+ |
A common issue is the dot-file rule. By default, the embed mechanism skips files and directories whose names start with . or _. That's a deliberate choice; editor backup files and version control metadata shouldn't end up in your binary. But if you actually want a .htaccess or a _redirects file, you need the all: prefix.
Assume the adminui/ folder contains index.html, app.js, style.css, and a hidden .well-known/security.txt.
Without the all: prefix the .well-known directory would be silently dropped. The compiler does not warn you; it just embeds the visible files and moves on. That silence is a frequent source of "but it works on my machine and breaks in CI" stories, especially with content management tools that store metadata in dot-files.
Two more pattern rules that bite people:
templates\receipt.html will not work on Windows. Write templates/receipt.html.../. The Go compiler reports pattern ../assets: cannot embed file outside the package.The single most useful thing to understand about embed.FS is that it implements fs.FS. That interface is the lingua franca of file-consuming APIs in modern Go. Once a function takes an fs.FS, it doesn't care whether the bytes live on disk, in a zip archive, in memory, or baked into the binary. The same template parser, the same HTTP file server, the same walk function work with all of them.
The diagram traces what happens to a folder of static assets. At build time the Go compiler reads the patterns in //go:embed, finds the matching files, and writes their contents into the binary's data section. At runtime the package-level variable becomes a live embed.FS value that knows how to serve those bytes back through the fs.FS interface. From there, any consumer that accepts fs.FS works without modification.
Here's the same idea in code. A small e-commerce admin UI ships a few static assets, and the same embed.FS value drives both an HTTP file server and a template parser.
The local variable sys is typed as fs.FS, but it holds the embed.FS value. Every method call goes through the interface, so the rest of the program could just as easily be reading from disk or from a zip archive without changing.
A few more fs helpers that work transparently:
fs.ReadFile(sys, name) is the function form of embed.FS.ReadFile. It works on any fs.FS.fs.ReadDir(sys, name) is the function form of embed.FS.ReadDir.fs.WalkDir(sys, root, fn) recursively walks an embedded tree.fs.Glob(sys, pattern) returns matching file names.The fs.Glob function doesn't know or care that the files live in the binary. It walks the embed.FS through the same interface it would use for a directory on disk.
A few path rules trip up new users:
embed.FS are relative to the source file's directory, just like the pattern was. If the file lives at internal/server/main.go and the directive says //go:embed templates, then inside embed.FS the path is templates/welcome.html, not internal/server/templates/welcome.html./), always, on every operating system. This matches the io/fs rules.Open("/templates/welcome.html") returns an error. The path is templates/welcome.html.//go:embed templates, the root contains templates/. If your directive is //go:embed templates/*.html, the root contains templates/ and inside templates/ you find the matched files.The root has one entry, the templates directory. Inside templates/ are the two HTML files. If the directive had been //go:embed templates/*.html instead, the root would still contain only templates/, because the FS preserves the directory structure the patterns referred to.
The first real-world payoff for embed.FS is shipping a single self-contained web server binary. Before embed, you had to deploy the binary plus a directory of assets and hope they stayed together. With embed, the assets live in the binary itself, and the standard library provides http.FS(fsys) to bridge any fs.FS to the http.FileSystem interface that http.FileServer expects.
A request to http://localhost:8080/index.html returns the embedded adminui/index.html. A request to /app.js returns adminui/app.js. Two pieces of glue make this work:
http.FS(fsys) adapts any fs.FS to the http.FileSystem interface used by http.FileServer. It's a one-line bridge.fs.Sub(fsys, "adminui") returns a sub-tree view of an fs.FS. Without it, the file server would expect URLs like /adminui/index.html. With it, the adminui prefix is stripped and URLs map cleanly to the files.Cost: http.FileServer does not set caching headers for embedded files any differently than for on-disk files. Browsers will revalidate using If-Modified-Since, which works fine because embed.FS reports a fixed modification time. For production, wrap the handler to add Cache-Control headers explicitly.
A common variation: the SPA pattern where the server returns index.html for any path the static files don't cover, so the client-side router can take over. The handler tries to open the requested path inside the embedded FS; if that fails, it rewrites the URL to / and lets the file server return index.html. Both code paths go through the same embed-backed fs.FS, so the data lives in exactly one place.
The second large win for embed.FS is template loading. Before embed, an HTML server typically called template.ParseFiles("templates/welcome.html", "templates/receipt.html", ...) at startup. If the templates folder wasn't deployed alongside the binary, the server crashed on startup with no such file or directory. With embed, the templates live in the binary, and template.ParseFS (added in Go 1.16 alongside embed) reads them through any fs.FS.
Assume templates/receipt.html is:
template.ParseFS takes an fs.FS and a list of glob patterns. It opens each matching file through the interface, reads it, and registers a template under the file's base name (so templates/receipt.html becomes a template named receipt.html). The rest of html/template works exactly as it does with ParseFiles.
The same pattern works for text/template, with text/template.ParseFS. The choice between the two depends on whether you're rendering HTML (where automatic escaping matters and you want html/template) or producing other text formats like email plaintext or CSV (where text/template is fine).
A subtle gotcha worth flagging: ParseFS registers each template under its base name, so if you have emails/welcome.html and pages/welcome.html they collide. Either rename one or use ParseFS twice with distinct patterns to keep them in separate template sets.
Cost: template.ParseFS parses all matched templates at startup, so a folder of 1000 templates adds noticeable startup time and memory. For a typical e-commerce site with a few dozen templates the cost is invisible.
The pattern of "embed a directory, then strip the prefix" is so common that fs.Sub deserves its own treatment. It takes an fs.FS and a directory name, and returns a new fs.FS that views the named directory as the root.
Both calls return the same bytes. The only difference is which path you pass. The reason to bother with fs.Sub is rarely about ergonomics; it's about isolation. When you pass an fs.FS to a third-party library, that library can read anything inside the FS. Using fs.Sub to hand it a narrow view of just the directory it needs is a clean way to limit what's accessible without trusting the library to behave.
This composes well with HTTP serving. Most web frameworks expect the root of the file server to map to the URL root. fs.Sub does that mapping in one line:
Two strippings here, doing different things. http.StripPrefix removes /static/ from the URL before handing it to the file server. fs.Sub removes static/ from the embedded path before the file server starts looking. Both are needed because the URL prefix and the embed path are independent of each other.
For tools that need to know exactly what's inside the embedded FS (test fixtures, asset manifests, migration runners), fs.WalkDir is the right hammer. It recurses through directories in lexical order and calls a function for each entry.
A simple migration runner could open each file, read its bytes, and execute them in order against the database. Because the files are embedded, the binary is self-contained: a single myapp migrate command applies the right schema with no separate migrations folder to ship.
The fs.WalkDir function visits directories before their contents and skips children when the callback returns fs.SkipDir. That makes it easy to filter out directories you don't care about:
The walker visits adminui/node_modules (the directory itself), the callback returns fs.SkipDir, and the walker skips the entire subtree. Useful when you've embedded a folder that contains things you don't want to enumerate.
A natural use case for embed.FS is shipping database schema migrations with the binary. The migration files sit in a migrations/ folder during development, get embedded at build time, and run in order at startup or via a migrate subcommand. There's no separate migrations folder to deploy, and the binary always has exactly the migrations it was built with.
fs.ReadDir enumerates the files, the sort makes ordering deterministic, and fs.ReadFile reads each file's bytes. Real migration libraries (golang-migrate, goose, atlas) all accept an fs.FS so they can run on embedded migrations directly. You don't have to write the runner yourself in production; you just hand them the embed.FS.
Cost: embedded SQL files are loaded into memory in full when you read them. For a typical migrations folder of a few KB each, that's nothing. If you ever embed multi-MB files and read them eagerly, the memory shows up in your binary's RSS at startup.
The trade for self-contained binaries is that every embedded byte goes into the binary's data section at build time. Strings, images, templates, SQL files: they all add to the final executable's size byte for byte. The Go linker doesn't compress embedded data by default.
If large-catalog.json is 10 MB, the resulting binary is at least 10 MB larger than the same program without the directive. Run go build and check with ls -lh or stat. The data is mapped from the binary's read-only segment at runtime, so reading from embed.FS is fast (effectively pointer arithmetic into already-loaded memory) but the on-disk binary size reflects the embedded payload.
Two practical implications:
When binary size matters, the usual mitigations are:
| Strategy | When it helps |
|---|---|
| Compress assets before embedding (e.g., gzip CSS/JS) | Web assets that the browser can decompress |
| Embed only what's needed; leave large files out | When some assets can live on a CDN |
| Use build tags to embed assets only in production builds | Dev builds load from disk for hot reload |
Strip debug symbols with -ldflags="-s -w" | Removes ~25-30% of base binary size; doesn't shrink embedded data |
The compression option is worth knowing about. You can pre-compress files (e.g., gzip the CSS and JS at build time) and then either serve the gzipped bytes directly to clients that send Accept-Encoding: gzip, or decompress on the fly with compress/gzip for clients that don't. For text assets the savings are typically 60-80%, which translates directly into a smaller binary. For already-compressed formats like PNG, JPEG, or WebP, gzip is a waste and embeds the raw bytes is the right call.
The other escape hatch is build tags. A common pattern is to embed assets only in release builds and load from disk in dev builds for hot reload.
Build with go build -tags dev for development; build normally for production. Both files expose assets.Static as an fs.FS, so the rest of the program doesn't care which one is in play.
os.DirFS(path) returns an fs.FS rooted at a real filesystem directory. It's the natural development-time pair to embed.FS. They share the same interface, so swapping between them is one line of code (we use this trick in the build tag pattern below).
| Property | embed.FS | os.DirFS |
|---|---|---|
| Data location | Inside the binary | On the host filesystem |
| Mutability | Read-only by construction | Read-only via the interface, but underlying files can change |
| Modification time | Fixed (zero) | Live file mtime |
| Symlinks | Followed at build time, embedded as content | Followed at read time |
| Performance | Memory-mapped, very fast | OS file system performance |
| Concurrency | Safe for concurrent reads | Depends on OS file handling |
The fixed modification time matters for HTTP caching. http.FileServer uses If-Modified-Since for revalidation, and embed.FS reports a zero mtime (time.Time{}), which means the file server effectively treats everything as "modified at the start of time". Browsers may revalidate on every request. If you need cache-friendly behavior, set explicit Cache-Control and ETag headers in your handler.
A small admin server that embeds templates, static assets, and a seed catalog in one binary:
One binary, three asset categories, no external files. The same embed.FS backs the template parser, the JSON endpoint, and the static file server. Each consumer uses the slice of the FS it needs through standard interfaces. Build with go build -o admin ./... and the resulting binary is self-contained.
10 quizzes