1. Why embed looks simple… and why that’s dangerous
When you use //go:embed for the first time, it feels like: “Oh, nice! I put help.txt next to it, and it’s with me forever.” That feeling is almost correct — but it hides a trap: embed works at build time and is very strict about paths and structure.
That’s why errors often look “illogical”: the file exists in the project, but can’t be read from embed.FS; you changed the file contents, but the program prints the old version; everything works on Windows, but your colleague on Linux doesn’t get it to work (or vice versa).
It’s important to accept this idea: embed is not your computer’s filesystem. It’s a “snapshot of resources” baked into the binary. And that snapshot has its own rules: which paths are allowed, which directories get included, how the “root” works, how the program size changes, and why your new text doesn’t show up without a rebuild.
A small historical fact for context: embedded files officially appeared in Go starting with version 1.16. It’s useful to remember this, because stable practices have already formed around embed — and some of those practices are exactly about avoiding common pitfalls.
2. Paths in embed: OS vs a path inside fs.FS
When we write programs, we’re used to paths “like in the operating system”: C:\projects\app\data\file.txt on Windows or /home/user/app/data/file.txt on Linux/macOS. In the os package and in filepath, almost everything is built around that model.
But as soon as you work with embed.FS (and, in general, with fs.FS), you enter a different universe with different rules.
A path inside fs.FS is not “where the file lives on disk.” It’s the file name inside a virtual filesystem. For embed.FS, that filesystem is formed from what you listed in //go:embed, and that is the name you must use when reading.
To avoid confusion, it helps to keep a simple table in mind:
| Property | OS path (regular file) | Path inside fs.FS (incl. embed.FS) |
|---|---|---|
| Separator | depends on the OS (\ or /) | always / |
| Absolute paths | exist (C:\..., /home/...) | usually no; the path is relative to the FS root |
| Tool for joining | |
more often path.Join or manually with / |
| Example | |
|
Note: the key point here isn’t even the “slash”, but the fact that embed.FS has its own root. It is not equal to the drive root, and it isn’t even the project root. It’s equal to what you embedded.
path vs filepath: how not to break resource reads
Sometimes the most expensive mistake in a programmer’s career is not a memory leak and not a deadlock, but “imported the wrong package.” path and filepath have very similar names, but they are intended for different kinds of paths.
filepath is about OS paths. It picks separators based on the platform and generally behaves “like the computer’s filesystem.”
path is about URL-like paths and paths inside fs.FS, where the separator is always /.
If you build a file name to read from embed.FS using filepath.Join, on Windows you’ll get backslashes, while embed.FS expects forward slashes. As a result, the file “is not found” even though it’s embedded.
Minimal example (in one small pain):
package main
import (
"embed"
"fmt"
"io/fs"
"path"
"path/filepath"
)
//go:embed assets/*
var embedded embed.FS
func main() {
bad := filepath.Join("assets", "help.txt")
_, err := fs.ReadFile(embedded, bad)
fmt.Println("bad read:", err) // bad read: open assets\help.txt: file does not exist
ok := path.Join("assets", "help.txt")
_, err = fs.ReadFile(embedded, ok)
fmt.Println("ok read:", err) // ok read: <nil>
}
Notice how “sneaky” this is: the code is the same in meaning, but the outcome is fundamentally different.
If you work with embed.FS or any fs.FS at all, treat this as the default rule: filepath is for disk paths, path is for fs.FS paths.
3. Resource structure and //go:embed patterns
Problems with embed are rarely about “syntax.” More often they are about structure: where resources live, what they’re called, what prefixes are used, and who is allowed to “know” those prefixes.
It’s very useful to choose a single root resources directory in the project ahead of time. For example, for our learning app (let it be a CLI utility for tasks called tasker) you can set up a structure like this:
tasker/
cmd/tasker/main.go
internal/assets/
assets.go
data/
help.txt
sample.json
The idea is simple: everything that gets embedded lives inside internal/assets/data/. Then you almost never make mistakes in //go:embed, because the “resource world” is separated from the “code world.”
assets.go might look like this:
package assets
import (
"embed"
"io/fs"
)
//go:embed data/*
var embedded embed.FS
func FS() (fs.FS, error) {
return fs.Sub(embedded, "data")
}
Notice the useful effect: the whole project no longer has to remember that inside embed.FS the files live as data/help.txt. External code just calls assets.FS() and then reads "help.txt".
//go:embed patterns: what actually ends up in the binary
When you write //go:embed, you are effectively forming the list of files that will end up in the binary. And the main trap is that your brain sees a “folder,” while the compiler sees a “pattern and inclusion rules.”
There are two typical situations.
- Situation 1 — you embedded only the “top layer” of files. For example, assets/* usually includes files directly inside assets/, but it does not guarantee recursive inclusion of subdirectories. As a result, you add assets/templates/help.txt, and it doesn’t make it into the binary because the pattern didn’t match it.
- Situation 2 — you embedded too much. For example, the pattern was too broad, and temporary files, dumps, large test data end up in the binary — and then you wonder why your binary weighs like a small game.
A practically useful rule sounds boring, but it works: keep resources in a separate folder, and make the pattern as narrow as possible. Then you control the size and reduce the chances of “not embedding what you need.”
4. Diagnostics: what actually got embedded
When fs.ReadFile returns an error, beginners often feel like “trying another path at random.” It’s much more productive to quickly look at what is actually present in your embedded FS.
fs.ReadDir is perfect for this. Even if you delete this code later, it often saves tens of minutes.
package main
import (
"embed"
"fmt"
"io/fs"
)
//go:embed assets/*
var embedded embed.FS
func main() {
entries, err := fs.ReadDir(embedded, "assets")
if err != nil {
fmt.Println("readdir:", err)
return
}
for _, e := range entries {
fmt.Println(e.Name(), "dir?", e.IsDir())
}
}
If you expected to see help.txt, but you only see logo.png, then the problem isn’t reading — the problem is that the file never made it into the embed “snapshot.”
One more detail: if you use fs.Sub, it’s convenient to diagnose both before and after “re-rooting.” Before — to understand what’s embedded at all. After — to ensure you picked the right subdirectory.
5. The cost of convenience: size, rebuilds, and security
Binary size and memory
embed is convenient, but it literally puts file contents into your binary. Which means the binary grows. Sometimes this is fine: a couple of kilobytes in help.txt won’t bother anyone. Sometimes it suddenly becomes a problem: you embedded a large JSON of 15 MB, then a couple of images, then templates — and suddenly your “small CLI tool” weighs like an average sales-department presentation.
Plus there’s the second side: reading. When you do fs.ReadFile, you get the entire []byte at once. That is, the file is read fully into memory. For small files this is great. For large files, you suddenly start dealing with “memory,” even though you just wanted to show help.
A small diagram to remember where the cost is:
flowchart TD
A[File on disk] --> B["//go:embed"]
B --> C[Binary gets bigger]
C --> D[fs.ReadFile]
D --> E["Whole file in memory as []byte"]
For reasonable learning projects, the recipe is simple: embed only what you truly need “in the box,” and try to keep resources small. embed is not a freight container; it’s more like a neat backpack.
Rebuilds: why you changed a file, but the program didn’t change
A very common situation looks like this: you edit help.txt, run the program, and see the old text. And the first thought is: “Go didn’t update the file.”
In reality, it’s simpler: the embedded resource is fixed at build time. If you run an already-built binary, it will contain the old version of the file — because it was built with the old version.
In an IDE this is especially tricky: sometimes it feels like you “just run,” but in fact an old artifact runs if the IDE decided a rebuild is not needed (or you are running the wrong configuration). So a useful habit is: when changing embedded resources, pay attention to whether a rebuild actually happened.
You can make a simple “control print” of the help text length to confirm the binary was updated:
package main
import (
_ "embed"
"fmt"
)
//go:embed help.txt
var help string
func main() {
fmt.Println("help length:", len(help)) // help length: 123
}
If the length doesn’t change, you are running an old build (or you are editing the wrong file — that happens too, especially when you unexpectedly have two copies of help.txt).
Security: embed is not a hiding place
There is a very important point that people often want to ignore, especially when “it’s so convenient”: if you embedded a file into a binary, it physically exists inside it. That means anyone who gets the binary can potentially extract that file.
So the golden rule is: embedded data must not be considered secret. No passwords, tokens, API keys, private configs, “it’s only for internal use” — all of that eventually becomes a postmortem story.
In the learning app tasker we can safely embed help, examples, output templates, sample data. But as soon as we’re talking about secrets, that’s a different storage model (and embed is not the right tool).
6. Common mistakes when working with embed
Mistake #1: joining paths for embed.FS via filepath.Join.
This is an almost guaranteed source of cross-OS problems. filepath builds a path “like on your system,” but embed.FS expects “like in fs.FS,” i.e., with /. As a result, you get “file does not exist” even though the file is embedded. The habit is easy to fix: for FS paths, use path.Join or agree in advance on strings with /.
Mistake #2: expecting assets/* to embed everything recursively and forever.
A beginner sees * and thinks “everything.” But the compiler sees a specific matching rule and may not include files from subdirectories. The result is that some resources “disappear.” Usually, this is fixed by project structure: keep embeddable files in a predictable folder and verify contents via fs.ReadDir, especially when you add new subdirectories.
Mistake #3: spreading path strings across the whole project.
Today you wrote "assets/help.txt" in one place, tomorrow "data/help.txt" in another, the day after you introduced fs.Sub and forgot to update half the calls. The program starts to look like a quest: “find the right prefix.” The solution is boring but grown-up: one package owns the resource structure and exposes either an fs.FS or small helpers like ReadHelp(), ReadSample(), so the rest of the code doesn’t think about internal paths at all.
Mistake #4: being surprised that you changed a file, but the program shows the old version.
This is the classic confusion: “changed sources” vs “rebuilt the binary.” embed bakes files at build time, so an old binary will always contain old data. If you are testing changes to a resource, make sure an actual rebuild occurred, especially in an IDE.
Mistake #5: embedding large files and not noticing the cost.
A couple of text files is fine. But if you start embedding large JSON/CSV/images, the binary grows, and fs.ReadFile also reads everything into memory at once. It’s not “forbidden,” but it often comes as a surprise. Usually it’s enough to keep resources compact and embed only what the program truly cannot start without.
GO TO FULL VERSION