How to Embed a Single File with go

embed

Use the `embed` package to embed a single file by declaring a variable with the `//go:embed` directive pointing to the specific file path.

The missing config file problem

You ship a command-line tool. It works perfectly on your machine because the default configuration lives in ./defaults/config.json. You zip the binary and send it to a colleague. They run it. It crashes. The file isn't there. You could tell them to copy it manually, or you could bake it directly into the executable. Go gives you a compiler directive that reads a file at build time and stitches its contents into the binary. No external dependencies. No missing files at runtime.

How the directive actually works

The //go:embed comment tells the Go compiler to treat the next variable declaration as a container for file contents. When you run go build, the compiler opens the specified file, reads every byte, and generates a Go constant or variable holding that data. The file stops being an external dependency. It becomes part of the program's read-only memory segment. Think of it like printing a map directly onto the cover of a travel guide instead of telling the reader to check a separate atlas. The data travels with the code.

The simplest approach: raw bytes

Here is the most direct way to embed a single file. You declare a variable of type []byte or string, place the directive on the line immediately above it, and the compiler handles the rest.

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

//go:embed config.json
// The compiler replaces this variable with the file contents at build time
var configData []byte

type Config struct {
	AppName string `json:"app_name"`
	Port    int    `json:"port"`
}

func main() {
	var cfg Config
	// Unmarshal directly from the embedded bytes
	if err := json.Unmarshal(configData, &cfg); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("App: %s, Port: %d\n", cfg.AppName, cfg.Port)
}

The directive must sit directly above the variable declaration with no blank lines between them. The path inside the comment is relative to the Go source file containing the directive, not the current working directory when you run the program. If config.json lives in a defaults folder, you write //go:embed defaults/config.json. The compiler resolves the path during the build step. If the file does not exist at that moment, compilation fails.

The gofmt tool does not reorder or modify the directive. It treats the comment as part of the variable declaration block. Run gofmt on save and let it handle indentation. The community expects consistent formatting, and the tool enforces it automatically.

Goroutines are cheap. Embedded files are static.

What happens under the hood

When the compiler processes the directive, it does not generate code that opens the file at runtime. It reads the file, escapes the contents if necessary, and injects them as a static array or string literal into the generated object file. At runtime, the variable points to a read-only section of memory. Accessing it is as fast as reading any other in-memory variable. There is zero filesystem I/O. There is no open, read, or close system call. The operating system never sees the embedded file.

This design choice keeps the runtime footprint tiny. You trade a slightly longer build time for instant access and complete deployment independence. The binary carries its own data. The Go build cache tracks embedded files alongside source files. If you modify config.json and run go build again, the compiler detects the change and recompiles the package. You do not need to delete the cache manually. The build system handles dependency tracking automatically.

Memory layout matters here. Embedded data lives in the .rodata segment of the executable. The garbage collector never scans it. You can safely pass slices of embedded bytes to other functions without worrying about allocation pressure. The data is immutable and survives the entire program lifetime.

Trust the compiler. It bakes the file, not the path.

When you need filesystem semantics

Raw bytes work great for configuration files, templates, or small assets. Sometimes you want the convenience of the io/fs interface. Maybe you plan to add more files later, or you want to pass the embedded data to a library that expects a fs.FS. Go provides embed.FS for this exact scenario.

package main

import (
	"embed"
	"fmt"
	"io"
	"log"
)

//go:embed config.json
// embed.FS implements the io/fs.FS interface for embedded files
var f embed.FS

func main() {
	// Open behaves like os.Open but reads from the embedded set
	file, err := f.Open("config.json")
	if err != nil {
		log.Fatal(err)
	}
	// Always close the file handle to satisfy the io.Closer contract
	defer file.Close()

	// ReadAll loads the entire file into memory
	content, err := io.ReadAll(file)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(content))
}

The embed.FS type implements the standard fs.FS interface. You can pass it to http.FileServer, template.ParseFS, or any function that accepts a filesystem. The compiler still bakes the data into the binary, but it wraps it in a virtual filesystem layer. This adds a small amount of overhead compared to raw bytes, but it unlocks compatibility with the broader Go ecosystem.

Here is a realistic example using embed.FS with the standard library template engine. This pattern appears frequently in web applications that ship with default HTML layouts.

package main

import (
	"embed"
	"html/template"
	"log"
	"net/http"
)

//go:embed index.html
// The template engine expects an fs.FS to parse templates from
var templates embed.FS

func handler(w http.ResponseWriter, r *http.Request) {
	// ParseFS reads the file from the embedded filesystem at startup
	tmpl, err := template.ParseFS(templates, "index.html")
	if err != nil {
		log.Fatal(err)
	}
	// Execute writes the rendered HTML directly to the HTTP response
	if err := tmpl.Execute(w, nil); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
	}
}

func main() {
	http.HandleFunc("/", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The template.ParseFS function accepts the embedded filesystem and a pattern. It parses the template once and caches it. Subsequent requests reuse the compiled template. This avoids parsing overhead on every HTTP call. The convention here is to parse templates during initialization, not inside the request handler. Keep request handlers fast and stateless.

Interfaces are accepted, structs are returned. Pass embed.FS where an fs.FS is expected.

Common pitfalls and compiler feedback

Developers often assume the path is relative to the module root or the working directory. It is not. The path is relative to the .go file containing the directive. Misplacing the file causes an immediate build failure. The compiler complains with could not embed file: open path/to/file: no such file or directory.

Another frequent mistake involves variable types. The directive only works with string, []byte, or embed.FS. If you declare var configData int above the directive, the compiler rejects it with //go:embed only supports variables of type string, []byte, or embed.FS. Stick to the supported types.

Wildcards are supported, but they are unnecessary for a single file. Writing //go:embed *.json works, but it silently includes every JSON file in the directory. If a test fixture or temporary file appears, it gets baked into the binary. Explicit naming prevents accidental bloat. The compiler will warn with pattern requires exactly one file, but N matched if you use a wildcard that resolves to zero or multiple files when you intended one.

Error handling remains standard Go. The embed.FS.Open method returns an error if the file is missing from the embedded set. You handle it with the familiar if err != nil pattern. The community accepts this boilerplate because it makes failure paths explicit. Do not ignore the error. If the file is missing, the program should fail fast rather than crash later with a nil pointer dereference. When you intentionally discard a return value, use the underscore _. Writing content, _ := io.ReadAll(file) signals that you considered the error and chose to drop it. Use it sparingly with embedded files, since missing data usually indicates a build configuration problem.

Build constraints interact directly with the directive. If you mark a file with //go:build linux, the compiler only processes the //go:embed directive inside that file when building for Linux. The embedded data will not exist in the Windows or macOS binaries. This is useful for platform-specific certificates or configuration defaults. The compiler enforces this strictly. You cannot conditionally embed files at runtime. The decision happens during compilation.

The worst goroutine bug is the one that never logs. The worst embed bug is the one that silently ships the wrong file.

Choosing the right container

Use a []byte variable when you need raw data for parsing, hashing, or passing to a library that expects a byte slice. Use a string variable when the file contains text you will manipulate directly, such as HTML templates or log messages. Use embed.FS when you need to pass the data to functions that expect an io/fs.FS interface, or when you anticipate adding more files to the same embedded set later. Use external file loading when the data changes frequently or exceeds a few megabytes, since embedding large files increases binary size and build time.

Where to go next