How to Use go

embed for CLI Templates and Assets

Cli
Use `go:embed` to compile static assets like HTML templates, JSON configs, or binary files directly into your Go binary, eliminating the need for external file dependencies or complex bundling steps.

The missing file problem

You build a CLI tool. It works perfectly on your machine. You zip the executable, send it to a colleague, and they run it. The program crashes immediately. The error says it cannot find templates/default.json. You forgot to include the asset directory in the distribution. Or worse, you wrote a shell script to copy files into a dist folder, and now your build pipeline has two steps instead of one. Go solves this by baking assets directly into the executable at compile time. The //go:embed directive turns your source tree into a self-contained binary.

What the directive actually does

Think of go:embed like a backpack with built-in pockets. Instead of carrying a separate bag for your snacks and another for your charger, you stitch the compartments into the main pack. When you compile, the Go toolchain reads the files you point to, converts them to bytes, and injects them into the binary's read-only data segment. At runtime, those bytes live in memory just like your code. You access them through the standard io/fs interface, which means every function that accepts a file system already knows how to read your embedded assets. No custom loaders. No special runtime libraries.

The compiler does the heavy lifting. You just point and compile.

The smallest possible embed

Start with the smallest possible case to see the compiler at work.

package main

import (
	"embed" // Required package for the embed.FS type and directive support
	"fmt"
)

//go:embed greeting.txt
// Tells the compiler to read this file and assign it to the variable below.
var greeting string

// PrintGreeting outputs the embedded text to stdout.
func PrintGreeting() {
	// The string is already in memory. No disk I/O happens here.
	fmt.Println(greeting)
}

func main() {
	// Calls the function that prints the compile-time injected string.
	PrintGreeting()
}

The directive must sit immediately above the variable declaration. You cannot place comments or blank lines between them. The variable type dictates how the compiler stores the data. A string variable stores UTF-8 text. A []byte variable stores raw binary data. The embed package must be imported, even if you never reference it directly in your code. The compiler uses the import to verify the directive is valid.

Embedding a string is just compile-time copy-paste.

How the compiler transforms your code

When you run go build, the compiler scans the source file for //go:embed directives. It resolves the file paths relative to the location of the Go file containing the directive. It reads the matched files, validates that they exist, and replaces the variable declaration with the actual file contents. The resulting binary contains the asset data in its .rodata section. At runtime, accessing the variable reads directly from that memory region. There is no filesystem lookup. There is no network call. The data is already loaded.

This behavior changes how you think about distribution. You no longer need to ship a directory alongside your executable. You ship one file. The go command handles the rest.

Serving a directory of templates

Real tools rarely ship a single text file. They ship directories of templates, configs, or static assets. The embed.FS type handles this pattern cleanly.

package main

import (
	"embed" // Provides the FS type for embedding entire directories
	"fmt"
	"html/template"
	"os"
)

//go:embed templates/*
// Captures every file inside the templates folder at compile time.
var templateFS embed.FS

// RenderWelcome parses and executes the welcome template with provided data.
func RenderWelcome(appName string) error {
	// Open the file from the embedded filesystem, not the local disk.
	tmplContent, err := templateFS.ReadFile("templates/welcome.html")
	if err != nil {
		// Wrap the error to preserve the call stack context.
		return fmt.Errorf("read template: %w", err)
	}

	// Parse the HTML template from the byte slice.
	tmpl, err := template.New("welcome").Parse(string(tmplContent))
	if err != nil {
		return fmt.Errorf("parse template: %w", err)
	}

	// Execute the template and write directly to standard output.
	return tmpl.Execute(os.Stdout, map[string]string{
		"AppName": appName,
	})
}

func main() {
	// Run the renderer and exit with a non-zero code on failure.
	if err := RenderWelcome("DeployBot"); err != nil {
		fmt.Fprintf(os.Stderr, "failed: %v\n", err)
		os.Exit(1)
	}
}

The embed.FS type implements the fs.FS interface. That interface defines methods like Open, ReadFile, and Glob. Because it implements the standard interface, you can pass templateFS to any third-party library that expects a file system. The templates/* glob pattern matches all files directly inside the templates directory. It does not recurse into subdirectories unless you use templates/**/*. The ReadFile method takes a path relative to the directory containing the Go file, not relative to your project root.

Treat embedded files like read-only configuration. Never assume they exist on disk.

Where things break

Path resolution is the most common trap. The directive is relative to the Go file, not the go.mod file. If you move the source file to a different package directory, the embed path breaks. The compiler catches this early. If the pattern matches nothing, the build fails with pattern templates/*: no matching files found. If you forget to import the embed package, the compiler rejects the file with undefined: embed.FS. If you try to embed a file that does not exist yet, the build stops immediately. You cannot defer validation to runtime.

Another hard limit is mutability. Embedded data lives in the read-only segment of the binary. You cannot write back to it. If your CLI needs a configuration file that users edit between runs, embed a default version and copy it to os.UserConfigDir() on first execution. The embedded copy stays pristine. The user copy lives on disk.

Goroutine leaks are irrelevant here, but channel leaks are not. If you spawn background workers that read from embedded templates and wait on a channel that never closes, your process hangs. Always provide a cancellation path for long-lived goroutines, even when they only read static data.

The compiler catches missing files early. Trust the build step.

When to embed and when not to

Use //go:embed with a string or byte slice when you need a single static file like a license text or a small JSON schema. Use embed.FS when your tool ships a directory of assets that need to be traversed or opened dynamically. Use standard file I/O when the asset changes frequently or users must edit it between runs. Use a separate asset server or CDN when the files are large media like images or videos. Use build constraints when you need different assets for different operating systems or architectures.

Keep the binary small. Embed only what the tool cannot function without.

Where to go next