Shipping a Go program used to mean shipping the binary plus a folder of HTML templates, CSS, SQL migrations, and config defaults that the program loaded at runtime. Lose one of those files in the wrong directory and the service refused to start. Go 1.16 added the //go:embed directive so the compiler can bake those files directly into the binary, turning deployment back into "copy one executable and run it." This lesson covers how //go:embed works, the three target types it supports, the pattern rules and constraints, and the standard library APIs that consume the result.
Before Go 1.16, embedding static assets in a binary required a third-party tool. The popular options were go-bindata, packr, and statik. They all worked the same way: read each asset file, generate a .go source file that contained the bytes as a string or byte slice literal, and check that generated file into the repository. The build then compiled the generated source alongside everything else.
The pre-1.16 workflow had real costs:
.go file full of byte literals. Code review tooling, diffs, and IDEs all groaned under the weight.//go:embed collapses all of that into a compiler-supported comment directive. There's no code generation step, no third-party install, and the embedded files appear in the binary at build time without an intermediate generated source file. The standard library handles it natively, so the API is the same in every Go program.
A Go 1.16+ project that previously used go-bindata typically replaces a hundred lines of build glue with a single line above a variable declaration:
That comment is the entire mechanism. The Go compiler reads it, finds the matching files, and embeds their contents directly into the program.
The directive is a magic comment. It must appear immediately above a package-level variable declaration, with no blank line between the comment and the var. The comment text is //go:embed followed by one or more space-separated patterns.
Assume welcome.txt sits next to main.go and contains:
A few rules apply to where the directive can appear:
var declaration. A blank line, a doc comment, or any other line between them breaks the association and the compiler will report go:embed cannot apply to var inside func or misplaced go:embed directive.var. You cannot embed into a local variable inside a function.string, []byte, or embed.FS. Anything else is a compile error.Even when the target variable is a plain string or []byte, the package must import "embed". The directive is only recognized in files that import the embed package, even if your code never references any symbol from it.
For string and []byte targets you don't use any name from the embed package, so the import has to be the blank form:
For embed.FS targets you use the embed.FS type directly, so the import is the normal form:
Forget the import and the compiler emits:
The reason for the rule is so that grep'ing for "embed" in a file's imports is enough to tell whether the file uses the directive. The compiler enforces the convention rather than guessing.
A variable annotated with //go:embed must be one of string, []byte, or embed.FS. The choice depends on whether you're embedding one file or many, and whether the contents are text or binary.
| Target type | Holds | When to use | Example pattern |
|---|---|---|---|
string | Contents of a single file as UTF-8 text | Banners, license text, one HTML template, default config | //go:embed welcome.txt |
[]byte | Contents of a single file as raw bytes | Binary blobs, images, single PDFs, when you need a mutable copy | //go:embed assets/logo.png |
embed.FS | Read-only filesystem tree of one or more files and directories | Multiple templates, static assets, SQL migrations, anything with structure | //go:embed templates static |
The text banner above used the string form. Here's a slightly larger example: a CLI store tool prints a welcome screen plus a footer pulled in from another single file.
Assume the project layout is:
With welcome.txt containing:
And footer.txt containing:
Each //go:embed directive applies only to the variable directly underneath it. You can have many such directives in one file, each pointing at a different file and a different variable.
Use []byte when the file isn't text or when you want a mutable copy. A common case for the e-commerce theme is a default product-category CSV that the loader parses on startup.
categories.csv:
Pick []byte over string when the contents are not guaranteed UTF-8, when you plan to pass them to a parser that wants a byte slice, or when you want a copy you can mutate without affecting the embedded data. The slice you get from a []byte target is a fresh copy each time the variable is read; the embedded bytes themselves are not modifiable.
embed.FS is the interesting target. It exposes a whole tree of files as a read-only filesystem rooted at the package directory. One pattern can match many files, and the result behaves like the fs.FS interface.
embed.FS is a concrete type defined in the standard library. It has three methods: Open(name string) (fs.File, error), ReadDir(name string) ([]fs.DirEntry, error), and ReadFile(name string) ([]byte, error). That's enough to satisfy fs.FS, fs.ReadDirFS, and fs.ReadFileFS, which is why functions like template.ParseFS and http.FileServer accept it directly.
The text after //go:embed is a space-separated list of patterns. Each pattern selects one or more files. The grammar is small, but the details matter.
The simplest pattern is a single literal filename, relative to the file containing the directive. //go:embed welcome.txt matches the file named welcome.txt in the same directory as the source file. //go:embed banners/welcome.txt matches that file under the banners subdirectory.
The filename is interpreted as a forward-slash path on every platform, including Windows. The directive is portable: write templates/order.html, not templates\order.html.
Patterns support the path.Match glob syntax. The wildcards are:
* matches any sequence of non-separator characters.? matches any single non-separator character.[abc] matches any one of the listed characters.//go:embed templates/*.html matches every .html file directly inside the templates directory but does not descend into subdirectories. //go:embed migrations/*.sql matches every .sql migration file in the migrations directory.
Naming a directory in the pattern embeds every regular file under that directory and all of its subdirectories. //go:embed templates matches templates/order.html, templates/email/welcome.html, and templates/email/admin/notice.html in one shot.
This is the simplest way to embed a whole asset tree. The glob templates/*.html only matches files in the top level; templates alone (no glob) recurses.
Cost: Recursive directory embeds pull in every regular file under the tree. Be careful not to accidentally embed test fixtures, large binaries, or .git directories that happen to live under the chosen root. Filter with explicit glob patterns when the tree contains files you don't want shipped.
By default //go:embed skips files and directories whose names begin with . or _. This matches the Go toolchain's general convention of treating dotfiles and underscore-prefixed names as hidden or generated.
If you want those files included, prefix the pattern with all:. So //go:embed all:templates includes templates/_draft.html and templates/.config.json, while plain //go:embed templates skips both.
The all: prefix applies to one pattern. Combine it with multiple patterns by writing all: only where you need it: //go:embed templates all:secrets includes hidden files under secrets but not under templates.
A single //go:embed line can list multiple patterns separated by spaces. All matches are merged into the same target. For an embed.FS target, the result is one filesystem containing every matched file.
That directive embeds both the templates directory and the static directory into one embed.FS. Inside the FS, files keep their relative paths: templates/index.html, static/style.css, and so on.
A single variable can also have multiple //go:embed lines stacked above it, which is sometimes clearer:
The two forms are equivalent.
A few restrictions prevent the directive from reaching outside the package directory or surprising you with platform differences:
/, contain .., or escape upward in any way. //go:embed ../../shared/config.json fails to build with pattern ../../shared/config.json: invalid pattern syntax.//go:embed nonexistent.txt produces pattern nonexistent.txt: no matching files found. This rule catches typos at build time.templates/order.html.Because embed.FS satisfies fs.FS, every standard library function that accepts an fs.FS works with embedded content without any conversion. This is the payoff for the fs.FS design.
The compiler reads the files on the left, the //go:embed directive bundles them into the binary, and the resulting embed.FS plugs into any consumer that accepts an fs.FS. The same FS value can be parsed as templates, served as static files, and read as raw bytes, without copies and without filesystem access at runtime.
A common case for an online store is a small "About Us" web page served by the same binary. http.FileServer accepts an http.FileSystem, and http.FS adapts any fs.FS (including embed.FS) to that interface.
A request to http://localhost:8080/about.html returns the embedded static/about.html, and /style.css returns the embedded CSS. The binary is fully self-contained; no static/ folder needs to be present alongside the executable in production.
Note the call to fs.Sub. The embedded FS has files at paths like static/about.html, but you want clients to fetch them as /about.html. fs.Sub returns a view of the FS rooted at the given directory, so paths inside the sub-FS are relative to that root. Without it, clients would need to request /static/about.html, which is leakier than necessary.
html/template and text/template both ship with a ParseFS function (added in Go 1.16 alongside embed) that reads templates from an fs.FS. For order confirmation emails, this means the templates ship with the binary.
templates/order-confirmation.html:
template.ParseFS looks up each pattern inside the supplied fs.FS exactly the way it would look up files on disk. The same code works whether you pass embed.FS, os.DirFS("templates"), or an in-memory fstest.MapFS, which is convenient for testing without touching the disk.
Cost: template.ParseFS parses the templates every call. For repeated rendering, parse once at startup and reuse the *template.Template. The embedded data lives in the binary anyway, so re-parsing just burns CPU.
fs.ReadFile reads any named file from an fs.FS into a byte slice. This works equally well on embed.FS:
fs.ReadFile is convenient when you have an embed.FS and just want the bytes of one file by name. For two or three files, embed.FS.ReadFile (the method on the type) does the same thing.
When you embed a directory tree, the paths inside the FS are rooted at the embedding source file's directory. That means files are addressed by paths like templates/order.html, not order.html. For many consumers (especially http.FileServer), this leading directory is noise.
fs.Sub returns a new fs.FS that's logically rooted at a subdirectory of the original. Paths inside the sub-FS are relative to that root.
Assume the embedded tree contains:
The assets/templates directory is not visible from staticSub because it sits outside the sub-FS root. This is a clean way to slice an embedded tree into the pieces different consumers care about: one sub-FS for http.FileServer, another for template parsing, another for SQL migrations.
fs.Sub returns an error if the named directory doesn't exist in the parent FS. Otherwise, the returned FS is exactly as capable as the parent (still read-only for embed.FS, still readable for fs.ReadFile and fs.Glob).
Files matched by //go:embed are read by the Go compiler at build time. The resulting bytes are linked into the binary, the same way a string literal is. Once the binary is built, the contents are fixed.
Two consequences follow:
go build runs, the regenerated version is what gets embedded.This makes //go:embed a poor fit for content that needs to change without redeploying (use a config file on disk or a remote source for that). It's a great fit for content that ships with the binary and changes only when the code does: HTML templates, SQL migrations, default configs, embedded help text, license notices, and CLI banners.
Cost: Embedded data inflates the binary. A 5 MB folder of images embedded into a Go binary makes the binary at least 5 MB larger. Most of the time this is fine, but for embedded systems with strict size budgets, consider whether the asset has to ship inside the binary or whether it could live in an external file.
A few real-world patterns recur often.
The pattern: ship a default config in the binary, but allow an external file on disk to override it. This is the smoothest first-run experience because the program works out of the box.
The embedded version is the safety net. The on-disk version wins when present.
Schema migrations ship with the binary, so deploying a new service version automatically deploys the matching schema. No separate "copy SQL files" step in the deploy script.
A real migration runner reads each file from the embedded FS in order and applies it to the database. Library projects like golang-migrate and goose accept an embed.FS directly because they were updated for the 1.16 feature.
Single-file string targets are perfect for embedded user-facing text. The CLI ships with its welcome screen baked in.
The whole "load this asset" subsystem collapses into one variable declaration.
Combining everything: one binary serves a small AlgoMart "About Us" page plus order confirmation rendering, all driven from embedded files.
templates/order.html:
Visiting /order renders the template. /static/style.css and /static/about.html come from the embedded static directory. The deployment artifact is one binary; the asset folders don't have to be present on the server.
A few mistakes come up frequently with //go:embed. They're all caught at build time, but the error messages are sometimes cryptic.
The most frequent mistake. The directive looks like a normal Go comment, so it's easy to think it stands on its own.
Build error:
Fix: Add import _ "embed" for string and []byte targets, or import "embed" for embed.FS targets.
For an embed.FS target, the embed package is used by name (the type embed.FS), so the import must be the normal form, not the blank form.
Build error:
Fix: Drop the blank, since embed.FS is used:
//go:embed rejects patterns that don't match anything. This is the compiler's way of catching typos and stale references.
Build error:
Fix: Correct the filename, or make sure the file is in the package directory tree.
Patterns are restricted to the package directory and its subdirectories.
Build error:
Fix: Move the file inside the package, or copy it during the build (with go generate) so the embedded copy sits next to the source. The directive deliberately refuses to reach outside the package because doing so would tie the build to the directory layout above the package.
The //go:embed directive must be immediately above the variable declaration. A blank line breaks the association.
Build error:
Fix: Remove the blank line so the directive sits flush against the var.
The directive only works on package-level variables.
Build error:
Fix: Move the variable out of the function. Package-level is the only valid scope.
10 quizzes