AlgoMaster Logo

embed Package

Medium Priority21 min readUpdated June 6, 2026

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 Shape of the Package

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.

MethodSignaturePurpose
Open(name string) (fs.File, error)Implements fs.FSOpen one file for reading
ReadFile(name string) ([]byte, error)Implements fs.ReadFileFSRead one file fully into memory
ReadDir(name string) ([]fs.DirEntry, error)Implements fs.ReadDirFSList 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.

Single Files vs Trees

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.

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.

Pattern Syntax

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.

PatternMatchesNotes
file.txtOne file by nameMost common form for string/[]byte
dir/file.txtOne file in a subdirectoryForward slash, even on Windows
dirEntire directory tree (recursive)Excludes dot-files and underscore-files
dir/*Direct children onlyOne level, no recursion
dir/*.htmlFiles matching pattern in dirGlob, not regex
templates static migrationsMultiple patternsSpace-separated
all:dirEntire tree including hidden filesGo 1.18+
all:dir/*Direct children including hiddenGo 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:

  • Embedded paths use forward slashes regardless of operating system. templates\receipt.html will not work on Windows. Write templates/receipt.html.
  • The pattern is rooted at the directory containing the Go source file with the directive. You cannot reach up the tree with ../. The Go compiler reports pattern ../assets: cannot embed file outside the package.

embed.FS as an fs.FS

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.

Path Semantics

A few path rules trip up new users:

  • Paths inside an 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.
  • Paths use forward slashes (/), always, on every operating system. This matches the io/fs rules.
  • There is no leading slash. Open("/templates/welcome.html") returns an error. The path is templates/welcome.html.
  • The root of the FS contains exactly the entries listed in the directive's patterns. If your directive is //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.

Serving Static Assets with http.FileServer

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.

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.

Parsing Templates with template.ParseFS

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.

fs.Sub for Sub-Trees

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.

Walking an Embedded Tree

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.

Embedding SQL Schemas and Migrations

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.

Binary Size and Bake-In Behavior

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:

  • Container images. A binary with 100 MB of embedded assets is 100 MB larger to push, pull, and store. For tiny edge or function deployments, this matters.
  • Cold starts. Larger binaries take longer for the OS to read from disk into memory when the process starts. For most servers this is negligible, but a serverless function with a 200 MB binary feels different from a 20 MB one.

When binary size matters, the usual mitigations are:

StrategyWhen 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 outWhen some assets can live on a CDN
Use build tags to embed assets only in production buildsDev 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.

How embed.FS Differs from os.DirFS

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

Propertyembed.FSos.DirFS
Data locationInside the binaryOn the host filesystem
MutabilityRead-only by constructionRead-only via the interface, but underlying files can change
Modification timeFixed (zero)Live file mtime
SymlinksFollowed at build time, embedded as contentFollowed at read time
PerformanceMemory-mapped, very fastOS file system performance
ConcurrencySafe for concurrent readsDepends 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.

Putting It Together

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.

Quiz

embed Package Quiz

10 quizzes