Skip to content

//go:embed puts files in the binary

Level: 201 · anyone who ships a template, a schema or a version file beside a binary, and once forgot to

One line: //go:embed pattern above a package-level string, []byte or embed.FS copies the matching files into the binary at build time; the running program reads them from its own memory, never from disk, so the sources can be deleted and the binary still has them — and io/fs walks the embedded tree in sorted order, which is why this page's listing is an answer key.

The script builds a mailer whose version comes from a VERSION file, whose logo is a few bytes of logo.bin, and whose e-mail templates are a directory. Then it deletes every source file, go.mod included, and runs the binary.

Verified output of go_embed_puts_files_in_the_binary_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ gofmt -l .
$ go build -o mailer .
$ rm -r templates VERSION logo.bin main.go go.mod
$ ls
mailer
$ ./mailer
version: "1.4.2\n"
logo: 6 bytes, 89 50 4e 47 0d 0a
ReadDir: cancelled.txt
ReadDir: shipped.txt
WalkDir: . dir=true
WalkDir: templates dir=true
WalkDir: templates/emails dir=true
WalkDir: templates/emails/cancelled.txt dir=false
WalkDir: templates/emails/shipped.txt dir=false
shipped.txt: "Dear %s,\nyour order %s has shipped.\n"
exit status 0

Reading the output

The binary ran with nothing but itself in the directory. ls shows mailer alone; the version, the six logo bytes and both templates came out of the binary. //go:embed is read by the go command when it builds the package, which is why the patterns are relative to the package directory and not to the working directory of the program that runs later (package embed ↗).

A string and a []byte take one file each, bytes intact. version is "1.4.2\n", newline included — an embedded file is not trimmed, and a program that compares it with "1.4.2" has a bug that only shows at run time. logo holds exactly the six bytes written, 89 50 4e 47 0d 0a; the []byte form is for data that is not text. Either form needs import _ "embed" when nothing else from the package is used — the compiler refuses the directive otherwise (the compiler errors).

An embed.FS takes a tree, and io/fs reads it. templates matched the directory, and fs.ReadDir, fs.WalkDir and ReadFile found templates/emails/shipped.txt under its path from the package directory, with forward slashes on every platform. The listing is sorted by name, cancelled.txt before shipped.txt, because embed.FS implements fs.ReadDirFS and fs.ReadDir ↗ returns entries sorted by filename. An embed.FS is an fs.FS, so it goes wherever one goes: http.FS, template.ParseFS, fs.Sub to drop the top directory.

What a directory pattern leaves out. A pattern that names a directory embeds everything under it except files and directories whose names begin with . or _; a pattern with a wildcard names the top-level entries it matches and so keeps them; all: in front of the pattern keeps them at every depth (embed, Directives ↗):

Verified output of go_embed_hidden_files_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ gofmt -l .
$ go run .
notes:         notes/drafts/roadmap.txt
notes:         notes/release.txt
notes/*:       notes/.swap
notes/*:       notes/_draft.txt
notes/*:       notes/drafts/roadmap.txt
notes/*:       notes/release.txt
all:notes:     notes/.swap
all:notes:     notes/_draft.txt
all:notes:     notes/drafts/_next.txt
all:notes:     notes/drafts/roadmap.txt
all:notes:     notes/release.txt

notes skipped .swap, _draft.txt and drafts/_next.txt; notes/* kept the two at the top, because the wildcard matched their names, and still skipped drafts/_next.txt, because drafts came in as a directory; all:notes kept all five.

What can go wrong is refused before the compiler runs. A pattern that matches nothing, a path with .., an absolute path, an unterminated quoted pattern; a //go:embed on a local variable, on a var with an initialiser, on an int, or in a file that does not import embed — each has its own message, recorded on the compiler errors page.

What nothing refuses. The file is copied when the package is built. A program that edits templates/emails/shipped.txt on disk and expects the change to show has the old bytes until it is rebuilt; nothing in the toolchain can tell that this was not what you meant (what vet and gofmt catch lists it). Read from os.DirFS when a file is meant to change under a running program; embed when it is part of the program.

go_embed_puts_files_in_the_binary_sh.sh and go_embed_hidden_files_sh.sh

go_embed_puts_files_in_the_binary_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# //go:embed copies files into the binary at build time: a text file into a
# string, a binary file into a []byte, a directory tree into an embed.FS. The
# proof is at the end: the sources are deleted and the binary still has them.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

say() { printf '$ %s\n' "$*"; }

go mod init example >/dev/null 2>&1 || exit 1

mkdir -p templates/emails
printf 'Dear %%s,\nyour order %%s has shipped.\n' >templates/emails/shipped.txt
printf 'Dear %%s,\nyour order %%s was cancelled.\n' >templates/emails/cancelled.txt
printf '1.4.2\n' >VERSION
printf '\211PNG\r\n' >logo.bin

cat >main.go <<'GO'
package main

import (
    "embed"
    "fmt"
    "io/fs"
)

//go:embed VERSION
var version string

//go:embed logo.bin
var logo []byte

//go:embed templates
var templates embed.FS

func main() {
    fmt.Printf("version: %q\n", version)
    fmt.Printf("logo: %d bytes, % x\n", len(logo), logo)

    entries, err := fs.ReadDir(templates, "templates/emails")
    if err != nil {
        panic(err)
    }
    for _, e := range entries {
        fmt.Println("ReadDir:", e.Name())
    }

    err = fs.WalkDir(templates, ".", func(path string, d fs.DirEntry, err error) error {
        fmt.Printf("WalkDir: %s dir=%t\n", path, d.IsDir())
        return err
    })
    if err != nil {
        panic(err)
    }

    body, err := templates.ReadFile("templates/emails/shipped.txt")
    if err != nil {
        panic(err)
    }
    fmt.Printf("shipped.txt: %q\n", body)
}
GO

say 'gofmt -l .'
gofmt -l .
say 'go build -o mailer .'
go build -o mailer . || exit 1
say 'rm -r templates VERSION logo.bin main.go go.mod'
rm -r templates VERSION logo.bin main.go go.mod
say 'ls'
ls
say './mailer'
./mailer
echo "exit status $?"

go_embed_hidden_files_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# A directory pattern skips files whose names begin with . or _ ; a pattern
# with a wildcard names the top-level entries and so keeps them; the all:
# prefix keeps them at every depth.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

say() { printf '$ %s\n' "$*"; }

go mod init example >/dev/null 2>&1 || exit 1

mkdir -p notes/drafts
printf 'editor state\n' >notes/.swap
printf 'not ready\n' >notes/_draft.txt
printf 'published\n' >notes/release.txt
printf 'also not ready\n' >notes/drafts/_next.txt
printf 'planned\n' >notes/drafts/roadmap.txt

cat >main.go <<'GO'
package main

import (
    "embed"
    "fmt"
    "io/fs"
)

//go:embed notes
var plain embed.FS

//go:embed notes/*
var starred embed.FS

//go:embed all:notes
var everything embed.FS

func list(label string, files embed.FS) {
    fs.WalkDir(files, "notes", func(path string, d fs.DirEntry, err error) error {
        if !d.IsDir() {
            fmt.Printf("%-14s %s\n", label+":", path)
        }
        return nil
    })
}

func main() {
    list("notes", plain)
    list("notes/*", starred)
    list("all:notes", everything)
}
GO

say 'gofmt -l .'
gofmt -l .
say 'go run .'
go run .

What to do

  • Embed what belongs to the program and changes only with it — templates, migrations, a schema, a server's static assets, a VERSION file — and read from disk what changes while the program runs.
  • Use embed.FS for more than one file and hand it to the io/fs world: http.FS, template.ParseFS, fs.Sub.
  • The bytes are exact: trim the trailing newline of a text file yourself, and treat a []byte as bytes.
  • Write all: when hidden files matter, and keep patterns inside the package directory: .. is refused, and so is an absolute path.

Practice

Embed this program's own source file into the binary it becomes, and prove it by reading the copy back: print the first line, whether the copy declares package main, whether it holds its own //go:embed directive, whether it ends with a newline like every gofmt-formatted file, and how many lines it has.

Solution

go_embed_puts_files_in_the_binary_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: embed this program's own source file into the binary it becomes, and
// prove it by reading the copy back: the copy names the package, holds its
// own //go:embed directive, and ends with a newline like every gofmt file.
//
//  go build -trimpath go_embed_puts_files_in_the_binary_kata_go.go && ./go_embed_puts_files_in_the_binary_kata_go
package main

import (
    _ "embed"
    "fmt"
    "strings"
)

//go:embed go_embed_puts_files_in_the_binary_kata_go.go
var source string

func main() {
    lines := strings.Split(strings.TrimSuffix(source, "\n"), "\n")
    fmt.Println("first line:", lines[0])
    fmt.Println("declares package main:", strings.Contains(source, "\npackage main\n"))
    fmt.Println("holds its own directive:", strings.Contains(source, "//go:embed go_embed_puts_files_in_the_binary_kata_go.go"))
    fmt.Println("ends with a newline:", strings.HasSuffix(source, "\n"))
    fmt.Println("lines:", len(lines))
}

Verified output of go_embed_puts_files_in_the_binary_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

first line: // Kata: embed this program's own source file into the binary it becomes, and
declares package main: true
holds its own directive: true
ends with a newline: true
lines: 24

In other languages

  • The Rust library's std macros you have not met ↗ and A file is bytes ↗ — include_str! and include_bytes! are macros with a path relative to the source file rather than a directive on a variable; include_str! checks UTF-8 at compile time where Go's string accepts any bytes; and nothing in std embeds a whole tree, which is a crate's job.
  • C23 adds #embed ↗, the preprocessor doing what xxd -i and a generated array did before — one file into one initialiser, no tree and no file system.

Sources

  • embed ↗ — the directive's rules and the embed.FS type — and io/fs ↗ for ReadDir, WalkDir and Sub.
  • The Go 1.16 release notes ↗ — //go:embed is new in 1.16 — and the design draft ↗ behind it.
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 11, "Go Tooling" — the sections "Embedding Content into Your Program" and "Embedding Hidden Files".
  • Miki Tebeka, Effective Go Recipes (Pragmatic, 2024), chapter 14, "Building Applications" — Recipe 72, "Embedding Assets in Your Binary".
  • Go in Practice, 2nd ed. (Manning, 2025), chapter 10, "Sending and receiving data" — section 10.1.3, "Embedding files in a binary".