How to Use go

embed with Build Tags for Different Environments

Use build tags on separate source files to include different embedded assets for different environments.

The environment puzzle

You are building a command-line tool that ships with a default configuration file. Linux machines expect a JSON structure with Unix paths. Windows machines expect a different structure with backslashes and registry keys. You want the compiled binary to contain the correct file automatically, so users never have to hunt for a missing config or manually edit paths. You reach for go:embed and try to wrap the directive inside an if runtime.GOOS == "linux" block. The compiler immediately rejects it.

The embed directive does not care about runtime conditions. It runs before your program even starts. You cannot conditionally include a single directive inside one file based on environment variables or OS checks. The Go toolchain processes embed comments at compile time, and it requires every listed file to exist on disk when the build runs. Build tags solve this problem, but they work at the file level, not the line level. You conditionally include the entire source file instead of a single comment.

How the embed directive actually works

go:embed is a compile-time instruction that tells the Go compiler to pack files into a virtual filesystem inside your binary. The toolchain scans your source files for the //go:embed comment before it parses the actual Go syntax. It looks for the files listed right next to that comment. If the files exist, it reads their contents and generates a hidden embed.FS variable. If they do not exist, the build fails immediately.

Think of it like a shipping container. The compiler is the dock worker. It looks at the manifest (//go:embed), finds the boxes on the warehouse floor, and seals them into the container before the ship leaves the port. Once the container is sealed, you cannot swap out boxes based on the destination. You must prepare separate containers for different destinations. Build tags are the labels on those containers. They tell the compiler which source file to pack for the current build environment.

The minimal setup

Here is the simplest way to split embedded assets by operating system. Create two files in the same package. Each file gets a build tag at the very top. Each file declares the same variable name. The compiler includes only the file that matches the current OS.

//go:build linux

package main

import "embed"

// embed the Linux-specific configuration file
// the compiler reads this file at build time
// it packs the contents into the binary
var ConfigData embed.FS
//go:build windows

package main

import "embed"

// embed the Windows-specific configuration file
// the compiler reads this file at build time
// it packs the contents into the binary
var ConfigData embed.FS

Both files live in the same directory. They share the same package name. They declare the same variable. The //go:build tag at the top of each file tells the compiler which one to include. When you run go build on Linux, the compiler ignores the Windows file entirely. When you run it on Windows, it ignores the Linux file. The result is a single binary with exactly one embedded config file.

Build tags are strict. They must appear on the first or second line of the file. The second line must be blank if the tag is on line one. gofmt does not touch build tags, but it expects them to follow the standard format. Place them correctly and the toolchain handles the rest.

Walking through the build process

When you invoke go build, the compiler starts by reading every .go file in the package. It checks the build tags first. Files with tags that do not match the current GOOS or GOARCH are skipped. They are never parsed, never type-checked, and never compiled. Only the matching file enters the pipeline.

The compiler then scans the remaining files for //go:embed comments. It extracts the file paths listed in the comment. It opens those files on the host filesystem and reads their raw bytes. It generates a synthetic embed.FS variable that implements the io/fs.FS interface. This variable lives in your package namespace. At runtime, you can read from it using standard io/fs functions. No network calls. No missing files. The data is already in memory.

The virtual filesystem is immutable. You cannot write to it. You cannot delete files from it. It is a read-only snapshot of your project directory at the moment you ran go build. If you need to update the embedded files, you must rebuild the binary. This is a feature, not a bug. It guarantees that your deployed artifact contains exactly the assets you tested against.

A realistic configuration loader

Real applications rarely rely on embedded files alone. They fall back to user-provided configs, environment variables, or default values. Here is a practical loader that reads from the embedded filesystem, checks for a local override, and handles errors explicitly.

package main

import (
	"embed"
	"fmt"
	"os"
	"path/filepath"
)

// LoadConfig reads the embedded config or falls back to a local file
// it returns the raw bytes or an error if both sources fail
func LoadConfig(fs embed.FS) ([]byte, error) {
	// check for a user-provided config in the current directory
	// this allows developers to override the embedded default
	localPath := filepath.Join(".", "config.json")
	if data, err := os.ReadFile(localPath); err == nil {
		return data, nil
	}

	// fall back to the embedded filesystem
	// the compiler guarantees this file exists at build time
	data, err := fs.ReadFile("config.json")
	if err != nil {
		return nil, fmt.Errorf("read embedded config: %w", err)
	}

	return data, nil
}

The function checks the local filesystem first. If a file exists, it returns it immediately. If not, it reads from the embedded fs. The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an error behind a silent fallback. Every failure route is explicit.

When you call LoadConfig, you pass the embed.FS variable created by your build-tagged file. The function works identically on Linux and Windows. The only difference is which bytes live inside fs at runtime. The rest of your application treats the config as a normal byte slice. Parse it, validate it, and move on.

Where things break

Embedding files is straightforward until you miss a detail. The compiler catches most mistakes early, but the error messages can feel abrupt if you do not know what to expect.

If you forget the build tag on one of your platform-specific files, the compiler includes both files. It sees two declarations of the same variable name and rejects the program with ConfigData redeclared in this block. The fix is simple. Add the correct //go:build tag to each file. The compiler will then filter them correctly.

If you reference a file in the //go:embed comment that does not exist on disk, the build fails with embed: open config.json: no such file or directory. The compiler does not guess. It does not create placeholder files. It expects the exact path to exist relative to the source file. Move the file, rename it, or update the comment. The build will pass once the paths match.

If you try to embed a directory, the compiler packs the entire tree. You must handle paths correctly at runtime. Calling fs.ReadFile("subdir/config.json") works. Calling fs.ReadFile("config.json") fails if the file lives inside a subdirectory. The virtual filesystem preserves your project structure exactly. Navigate it the same way you would navigate a real disk.

If you attempt to write to the embedded filesystem, your program panics at runtime with io/fs: read-only filesystem. The embed.FS type is immutable by design. Copy the data to a temporary file or an in-memory buffer if you need to modify it. Never try to mutate the embedded tree directly.

Convention matters here. Public names start with a capital letter. Private start lowercase. Name your embedded variable ConfigData or StaticAssets, not config_data or MyConfigFile. Keep it descriptive and idiomatic. The rest of the Go ecosystem expects standard naming patterns.

Choosing your embedding strategy

Use separate build-tagged files when you need completely different assets per platform. Use a single go:embed directive with a directory when all environments share the same base files. Use runtime configuration loading when the file changes frequently and recompiling is impractical. Use environment variables for secrets and sensitive values that should never live in a binary.

Build tags filter source files, not lines. The compiler decides what enters the build before it ever sees your logic. Trust the toolchain. Let it handle the platform split. Keep your runtime code clean.

Where to go next