Skip to content
academia.sh

Lesson 08 / 12

Embedded Resources

A source file is measured as it binds to three separate Go types (a string, a byte slice, a filesystem) with the same content through the go:embed directive, and that content is shown to match the file sitting on disk exactly. The limiting measurement runs a compiled binary to show that it produces the same content even after the source file is deleted from disk: the link is built at compile time, and the file is never needed again at run time.

Published

In this lesson

  • Measures that the same file binds with identical content as a string, a byte slice and a filesystem through the go:embed directive
  • Shows that the embedded content matches the source file on disk exactly, by comparing them while the file still exists
  • Limits by running that a compiled binary produces the same content even after the source file is deleted from disk
Contents

The previous lesson passed three sources (a file, a buffer, a string) through the same reader interface and showed that all of them give the same result — a raw byte slice. Those three sources shared one property: they stayed reachable while the program was still running — the file sat on disk, the buffer sat in memory. This lesson asks a different question: can data a program needs be in the program’s hands even when it is never reachable while running? The answer is yes, and the method is not a read but a binding: placing the source file’s content directly inside the binary at compile time.

Term note: the word “embedding” used in this lesson shares its name with the struct embedding measured in the Composite Types topic (placing one type unnamed among another type’s fields), but it is a separate mechanism. Struct embedding was a relationship built between two Go types inside the source code; the embedded resource measured in this lesson instead carries a file’s content, from outside the source code, into a variable through the compiler itself. The two share only a name — one binds two Go types to each other, the other binds a file to a Go value.

Determinism note: no file path is printed in this lesson; the only things measured are the content’s length, its type, and whether the two representations are equal to each other.

This mechanism’s typical use cases are data that does not change while running but still needs to travel with the program: a configuration file’s default values, a command-line tool’s help text, a web server’s static assets. What they all share is that the content is part of the program — not data copied alongside it through a separate setup step.

The Source File

The embed directive (//go:embed) is a special comment written directly above a var declaration; the compiler reads it and places the specified file’s content into that variable at compile time. For the directive to work, the file to embed has to exist first — the shell block below creates the recipe.txt file that the rest of the lesson embeds, and reads it back to verify its content.

# recipe-file.sh — creates the source file that go:embed will embed
cat > recipe.txt <<'EOF'
the source travels into the package with code
EOF
cat recipe.txt
the source travels into the package with code

One File, Three Types

SD4. — The same recipe.txt file is embedded into three separate variables: a string, a byte slice, and a filesystem type (embed.FS). The goal is to show that the directive behaves differently depending on the target type, and that all three carry the same content.

// embedded.go — the same file is embedded as string, []byte and embed.FS, and their content is compared
package main

import (
	"embed"
	"fmt"
	"os"
)

//go:embed recipe.txt
var recipeText string

//go:embed recipe.txt
var recipeBytes []byte

//go:embed recipe.txt
var recipeFS embed.FS

func main() {
	fromFS, _ := recipeFS.ReadFile("recipe.txt")
	fromDisk, fileErr := os.ReadFile("recipe.txt")

	fmt.Printf("recipeText type=%T length=%d\n", recipeText, len(recipeText))
	fmt.Printf("recipeBytes type=%T length=%d\n", recipeBytes, len(recipeBytes))
	fmt.Println("text == byte conversion:", recipeText == string(recipeBytes))
	fmt.Println("text == embed.FS read:", recipeText == string(fromFS))
	fmt.Println("text == read from filesystem:", recipeText == string(fromDisk), fileErr == nil)
}
recipeText type=string length=46
recipeBytes type=[]uint8 length=46
text == byte conversion: true
text == embed.FS read: true
text == read from filesystem: true true

The three //go:embed directives are independent of each other; each reads the same recipe.txt, but produces a different representation depending on the target variable’s type. recipeText is a string, recipeBytes is a byte slice, and recipeFS is a small filesystem abstraction with methods like Open and ReadFile. Because all three come from the same source, their content matches exactly — the same 46 bytes come back whether compared through the byte conversion, read through embed.FS, or read live from the file itself with os.ReadFile. This last comparison builds a bridge to the previous lesson: while the file is still on disk, the embedded content and the content on disk are indistinguishable — as if the file were read twice, but one read happens at compile time and the other at run time.

Splitting the same file into three separate representations is not arbitrary; each representation fits a different way of using it. Code that uses the text directly as a string (a fixed message, a template source) prefers recipeText. Code that hands the content off to another binary format (a compression algorithm, a network body) prefers recipeBytes, because []byte is the type those kinds of interfaces expect. Code that needs to carry more than one file, or a directory structure, needs embed.FS — that need is measured in the next section.

Limiting Measurement: The Source File Is Never Looked Up at Run Time

The measurement so far ran while recipe.txt was still on disk, so the claim “the embedded content matches the file” could not be separated from the claim “the program depends on the file.” The step below separates the two: the same source is first compiled into a standalone binary, then the source file is deleted from disk, and only then is that binary run.

SD5. — The build step produces a separate binary with go build; go run is not used, because go run recompiles on every call and the source file would still be on disk at that moment. The goal here is to separate compiling from running.

# build-and-delete.sh — first it compiles, then the source file is deleted, then only the binary runs
go build -o body_program .
rm recipe.txt
./body_program
recipeText type=string length=46
recipeBytes type=[]uint8 length=46
text == byte conversion: true
text == embed.FS read: true
text == read from filesystem: false false

The first four lines of the output are exactly the same as the previous run: recipeText, recipeBytes and recipeFS still give the correct 46-byte content — even though recipe.txt is no longer on disk. Only the last line changes: os.ReadFile("recipe.txt") can no longer find the file, so both the comparison and the error check turn to false. This difference shows the real distinction between the embed directive and a live file read — the first three variables absorbed the content into themselves at compile time and never read the source again after that moment; the fourth line looks the file up again on every run.

On the topic’s measurement axis: the link from the source file to the embedded variable is not built at run time — it is already built at compile time. This is exactly where it differs from reading a file (the previous lesson’s subject): a read goes back to the source on every call, while embedding goes to the source only once, at compile time, carrying the result into the binary. This is as much a guarantee as a constraint: the distributed binary does not have to carry a separate data file alongside it, and that file being changed or deleted at run time never affects the program’s behavior.

This comes with a cost, the flip side of the guarantee: when the content is updated, the binary has to be recompiled too. The file read in the previous lesson could see a changed source immediately — reading the file again each time was enough. For an embedded resource this path is closed; the only way to update the content is to change the source file and recompile. Which approach is right depends on the content’s own nature: reading suits data that changes often, embedding suits data that should never change at all.

Multiple Files: Embedding by Pattern

The directive’s target does not have to be a single file name — it can also be a pattern. Because string and []byte can only carry a single file’s content, the only way to embed more than one file is embed.FS; the result is a small directory tree embedded inside the binary.

SD6. — A small two-file directory (resources/) is embedded into a single embed.FS variable through a pattern (resources/*.txt); the measurement reads the number of embedded files and each one’s length.

# resources-dir.sh — creates the two-file directory that will be embedded by pattern
mkdir resources
printf 'text from the first file\n' > resources/first.txt
printf 'text from the second file that is longer\n' > resources/second.txt
ls resources
first.txt
second.txt
// directory.go — the content of a directory embedded by pattern is listed and read
package main

import (
	"embed"
	"fmt"
	"sort"
)

//go:embed resources/*.txt
var resourceDir embed.FS

func main() {
	entries, _ := resourceDir.ReadDir("resources")
	names := make([]string, 0, len(entries))
	for _, g := range entries {
		names = append(names, g.Name())
	}
	sort.Strings(names)
	fmt.Println("embedded file count:", len(names))
	for _, name := range names {
		content, _ := resourceDir.ReadFile("resources/" + name)
		fmt.Printf("%s length=%d\n", name, len(content))
	}
}
embedded file count: 2
first.txt length=25
second.txt length=41

The resourceDir.ReadDir("resources") call behaves as if it were listing a real directory — it gives file names, whether they are files, their sizes — but what it lists is not on disk, it is inside the binary. This is possible because embed.FS satisfies the standard library’s filesystem abstraction (the fs.FS interface in the io/fs package); any tool that behaves like a filesystem can work without knowing whether the source is a real disk or a copy embedded inside the binary. This interface distinction is not rebuilt here with the server-side details established in the Networking and Data Formats topic; the only thing measured is that the embedded directory can be listed and read like a real one.

This is another example of the previous lesson’s io.Reader/io.Writer composition: fs.FS is likewise a narrow interface with just a few methods, and a function that expects it can work with os.DirFS("a/real/path"), with embed.FS, or with a handwritten fake filesystem built for tests. The resourceDir variable’s concrete type is embed.FS, but when you hand it to a function that expects fs.FS, that function never knows whether the content sits on a real disk or inside the binary — just as the read function in the previous lesson never knew whether the source was a file or a buffer.

Compile-Time Boundaries

For the embed directive to work, the target file has to exist in the package’s own directory at compile time. Testing these two constraints separately is possible with the compiles-style helper established in Go’s Design Goals lesson and used throughout the course: a function that writes the source into a temporary directory, attempts to compile it, and classifies the result as compiled/did-not-compile.

SD7. — Three sources are tried: an embed whose file genuinely exists, an embed whose file does not exist at all, and an embed that tries to reach outside the package’s own directory (../). The compiler’s error text is not printed here either; only the rejection class is read.

// compiles.go — the embed directive's compile-time constraints are tested with compiles
package main

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

func compiles(source string, otherFiles map[string]string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
	for name, content := range otherFiles {
		os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755)
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}

func makeSource(fileName string) string {
	return fmt.Sprintf(`package main
import (
	_ "embed"
)
//go:embed %s
var data string
func main() {}
`, fileName)
}

func main() {
	existing := compiles(makeSource("present.txt"), map[string]string{"present.txt": "content"})
	missing := compiles(makeSource("missing.txt"), nil)
	outsidePkg := compiles(makeSource("../outside.txt"), nil)

	fmt.Println("embedding an existing file       :", existing)
	fmt.Println("embedding a missing file         :", missing)
	fmt.Println("embedding a file outside package :", outsidePkg)
}
embedding an existing file       : true
embedding a missing file         : false
embedding a file outside package : false

The first of the three attempts compiles, the other two do not — but the two rejections are not for the same reason. The missing file fails the directive’s most basic condition: there is no content to embed at all. The file outside the package is rejected even though its content exists, because the embed directive can only reach files inside its own package’s directory and below; a path climbing to a parent directory is not accepted at compile time even if the file genuinely sits there. This is another difference between reading and embedding: os.ReadFile can reach any path the calling code can access, while the embed directive can never step outside the package’s own boundaries.

This boundary is not arbitrary: if a package carries an embed directive that reaches outside its own directory, someone who picks up the package elsewhere (another module, another machine) and compiles it cannot know where the source sits — the parent directory does not travel with the package. An embed confined to the package’s own directory guarantees the package is self-contained: copying it automatically copies every embedded resource with it.

One more constraint is not run here but is worth mentioning: files whose name starts with a dot or an underscore (.hidden, _helper) are not included in the directive’s default pattern; a directive that wants to embed them too needs an all: prefix added to the front. This belongs to the same family: the embed directive carries its own default rules for which files count as “really part of the source.”

Summary

  • The //go:embed directive places a file’s content into a Go variable at compile time; it produces a different representation depending on the target type (string, []byte, embed.FS). What crosses the boundary is still bytes; the target type itself rebuilds the type.
  • Three variables bound to the same file give content equal to each other and to a live read of the file on disk — while the file is still on disk.
  • Limiting measurement: a compiled binary still produces the correct embedded content after the source file is deleted — the link is built at compile time, the source is never needed again at run time, and only a live disk read (os.ReadFile) fails.
  • A pattern can embed more than one file into a single embed.FS variable; the result is a structure that satisfies the fs.FS interface and can be listed and read like a real directory.
  • The embed directive is limited by two compile-time constraints: the target file has to genuinely exist, and it cannot reach outside the package’s own directory; either one stops the build.
  • “Embedding” here is a separate mechanism from the struct embedding in the Composite Types topic: one is a relationship inside the code, the other is a link the compiler builds from a file to a variable.

Next Step

The link measured in this lesson was about time, but time itself was never measured — only the order “compiled first, then deleted, then run” mattered. This ordering completes the answer the topic’s first two lessons gave to the question of what a value crossing the boundary carries: the file, the buffer and the string never carried type information at all; the embedded resource does carry the content, but it does so by crossing the boundary at compile time, not at run time — the boundary itself shifts with time.

The next lesson asks about time itself: what a value carries and what it loses when it turns into text and comes back. What does a moment gain when it binds to a time zone, and how much of that gain survives being written to text and read back? There is no equivalent here of the “content is fixed” guarantee the embed directive gave — time, by definition, is bound to the very moment it is measured, and writing that moment to a string and reading it back produces a different kind of loss than reading an embedded file does.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close