1. Why embed files into a binary
Honestly, the idea “let’s put help.txt next to the app and read it” looks perfectly fine... until you try to send the program to a friend, deploy it to a server, or just run it from a different directory. Suddenly it turns out that “next to” is a philosophical concept, not a guarantee. The user runs the binary from another location, copies only the .exe, forgets about assets/, and your app starts getting sad.
embed solves exactly this pain: you can embed the required resources right inside the built binary. Then your program becomes self-contained: one file, and everything is inside. This is especially useful for CLI tools (help texts), sample data (sample CSV/JSON), templates, static pages, and basically anything you want to ship together with your program.
To lock in the mental image, think of a “suitcase”: a normal program often travels with a bunch of stuff next to it (files, directories), and with //go:embed you pack them into the suitcase at build time. Not magic—just careful engineering.
2. How //go:embed works
Go has a special category of comments that look like comments, but to the compiler they are more than just text for humans. //go:embed is exactly that: a compiler directive that says, “take this file/directory and put it into the binary.”
Important point: embedding happens at build time, not at runtime. That is, embed is not “reading a file,” it is “packing a file.” The program then no longer depends on the original help.txt existing on the user’s disk—it just uses what was baked into the binary.
It helps to keep a tiny lifecycle diagram in mind:
flowchart LR A[Files in the repository
help.txt, assets/] --> B[go build] B --> C[Binary with embedded data] C --> D[Program run
files on disk may be missing]
And yes: it’s still a comment. So if you forget a single character (for example, write // go:embed with a space), the compiler won’t be offended—it will simply ignore the directive, and you’ll be debugging “why it didn’t embed.”
//go:embed works only for package-level variables
Here’s a rule that many people break exactly once—and then remember forever.
The //go:embed directive applies only to package-level variables, i.e., declared “outside functions.” Not inside main(), not inside init(), not inside your handlers, and not in any of those wonderful places where it feels comfortable.
Why? Because embed is part of the build mechanics, and the compiler needs an “anchor”: a specific variable that always exists in the package. A local variable inside a function comes to life at execution time, but //go:embed must run earlier.
Here is an example that will not work (and that is good: better to not work than to work “sometimes”):
package main
import _ "embed"
func main() {
//go:embed help.txt
var help string // ❌ not allowed: local variable
_ = help
}
And here is the same idea, but correct—the variable is declared at the package level:
package main
import _ "embed"
//go:embed help.txt
var help string // ✅ package-level variable
func main() {
println(len(help)) // e.g.: 120
}
A bit later we will wire this into our training app so that the help command prints help without reading from disk.
3. Choosing the type: string, []byte or embed.FS
Before writing code, it is important to choose the “container” (receiver) that will hold the embedded data. In Go, embed has three most common options, and each one matches a different scenario.
A small table to see the differences clearly:
| What we embed | Variable type | When it is handy |
|---|---|---|
| One text file | |
help texts, templates, small configs, messages |
| One file as bytes | |
images, binary data, “do not treat as text” |
| A set of files/directories | |
many resources, directory structure matters |
The idea is simple: if you have one file and you know it is text—use string. If it is one file but “not necessarily text”—use []byte. If there are many files and you want to preserve the directory tree—you need embed.FS.
And yes, embed.FS is literally an “embedded file system,” which is convenient to use via the io/fs interfaces. Even large projects use the “add a subdirectory to the //go:embed line” approach when they need to pack a set of resources together with the program.
string: embedding help.txt
Let’s start with the most beginner-friendly scenario. Imagine we have a CLI app (let it be our training task manager), and we want tasker help to print help even if the user downloaded only the binary.
Create a help.txt file next to main.go (important: “next to” in the sense of your package source directory):
Tasker — a mini task manager.
Commands:
tasker help
tasker version
Now in Go code we embed this file into a string variable:
package main
import (
_ "embed"
"fmt"
)
//go:embed help.txt
var helpText string
func main() {
fmt.Print(helpText)
}
If you run it, you will see the file content printed by the program—without os.Open, without ReadFile, without “where is help.txt on prod?”. Just printing a string.
Pay attention to the import: here _ "embed" is enough. We do not use any names from the embed package, but we want the compiler to “see” the //go:embed directive. This is that classic “import for side effects/mechanics.”
[]byte: when a file should not be “text”
Now the second common case: there is a file that you “technically could” print as a string, but it is a bad idea. For example, some binary “logo,” a small image, or even just data where bytes matter, not characters.
Let’s make an example with an abstract logo.bin file (in practice, this can be any file). We embed it as bytes:
package main
import (
_ "embed"
"fmt"
)
//go:embed logo.bin
var logoBytes []byte
func main() {
fmt.Println("logo size:", len(logoBytes)) // logo size: 256
}
Why is []byte sometimes better than string? Because in a beginner’s head string quickly turns into “this is text and I can print it,” while printing binary data is like reading a book where QR codes replace letters: technically possible, but people around you will start to worry.
embed.FS: embedding a resources directory
When you have more than one resource file, you quickly run into organization problems: paths, folders, different texts, templates, sample data. At that point you want to “embed everything from assets/.”
That is what embed.FS is for. Important: here you need a normal import embed, because you use the embed.FS type in the declaration.
package main
import "embed"
//go:embed assets/*
var assets embed.FS
At this point beginners ask a very reasonable question: “OK, but how do I read a file from this?” Answer: via functions and interfaces from io/fs (fs.ReadFile, fs.ReadDir, etc.). But that is the next lecture; today our goal is to understand how to embed correctly and which type to choose, not how to retrieve things nicely.
4. Imports and paths in //go:embed
embed vs _ "embed"
It is very easy to get confused about imports, so let’s lay it out plainly.
If your receiver is string or []byte, you can use a blank import: import _ "embed"
The logic is: you do not use embed.FS and you do not call anything from the embed package, but the compiler needs to “enable” support for the //go:embed directive in this file.
If your receiver is embed.FS, then you need a regular import: import "embed"
Because embed.FS is a type from the embed package, and you actually reference the name embed.
This small detail saves time in practice. Otherwise you get the classic scenario: “I did everything right” → “why won’t it build” → “oh, I wrote the import wrong.”
Paths in //go:embed: relative to the package
Here is the important point, gently but firmly: in //go:embed you specify not absolute OS paths (like C:\Users\... or /home/...), but paths relative to the package, i.e., relative to the directory where the file with the directive lives.
In practice this means: you usually create a clear resources folder in the project (for example, assets/ or data/) and embed from there. This disciplines you: fewer random files get into the binary, and you clearly understand what counts as “resources” versus source code and junk.
One more human tip: treat //go:embed like a “build contract.” If you widen the pattern and accidentally embed extra stuff, the binary will grow, and you most likely will not notice immediately. So it is better to keep resources in a separate directory and embed exactly that.
5. Mini example: tasker help without reading from disk
Now let’s assemble a small piece that looks like real value. We will not dive into a full command parser (you already can do that); we will implement very simple routing based on arguments.
The help.txt file already exists. In code:
package main
import (
_ "embed"
"fmt"
"os"
)
//go:embed help.txt
var helpText string
func main() {
if len(os.Args) >= 2 && os.Args[1] == "help" {
fmt.Print(helpText)
return
}
fmt.Println("unknown command, try: tasker help") // unknown command, try: tasker help
}
This is not a “final CLI architecture” (not even close), but as a learning step it is perfect: you felt the mechanics of embed with your hands and got a real feature that does not depend on external files.
6. Common mistakes when working with //go:embed
Mistake #1: trying to use //go:embed inside a function.
Almost everyone does this once, because inside main() it feels “logical”: you need the help text here, so you declare it here. But embed works at build time and is tied to package-level variables. The right habit is to keep embedded variables outside functions, as global resources of the package.
Mistake #2: wrong import—either you forgot embed or used the wrong form.
If you embed into string/[]byte, _ "embed" may be enough. If you declare var x embed.FS, you need import "embed". Mix them up and you will get either a compile error about an unused import, or an unknown identifier, or “why didn’t the directive work.”
Mistake #3: trying to “embed multiple files” into a single string or []byte variable.
string and []byte are about one specific file’s contents. As soon as you have a set of files, you might think “let it all be one string.” In reality it quickly turns into a mess because you lose structure. For a set of resources, the normal path is embed.FS, i.e., a file tree in the binary.
Mistake #4: expecting embedded data to “update by itself” after you change the file.
embed bakes the content at build time. So if you changed help.txt but did not rebuild the program, the running binary will still have the old text. This is not a bug; it is the contract: embedded resources are part of the build artifact, not a “live file.”
Mistake #5: using an overly broad pattern and the binary suddenly grows.
It is very easy to say “I’ll embed everything,” and then discover you accidentally packed test data, large dumps, old images, and half your life. That hurts both binary size and project clarity. Good style is to keep resources in a separate folder and embed only that, like in mature repositories where subdirectories are explicitly added in //go:embed.
GO TO FULL VERSION