---
title: 'Embedded Resources'
source: 'https://academia.sh/en/courses/go-applications/embedded-resources'
course: 'The Standard Library and Network Applications'
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```bash
# 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.

```go
// 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.

```bash
# 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.

```bash
# 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
```

```go
// 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.

```go
// 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.
