JavaRush /Courses /Go SELF /embed.FS — reading embedded resources

embed.FS — reading embedded resources

Go SELF
Level 44, Lesson 1
Available

embed.FS — reading embedded resources

1. Why read resources from a binary at all

When a program becomes “real,” it suddenly includes not only .go files, but also “baggage”: help text, templates, config examples, test data, static UI files. And then an unpleasant thing becomes clear: if those files sit next to the binary, you can forget to include them in a release, lose them along the way, accidentally delete them, or put them in the wrong place.

Embedding solves exactly this problem: we make the required resources physically end up inside the executable file. In Go, there is the embed package for this, and the key “resource packaging” for a set of files is the embed.FS type. In practice, it means: “my binary contains a small file system, and I can read it.”

By the way, in real projects people really do maintain directories of third-party resources and include them in //go:embed so they are guaranteed to be part of the build. For example, the documentation for one large project explicitly says: to add a library, you must place it in a subdirectory and add that subdirectory to the //go:embed line.

What embed.FS is and why it is a “file system”

When you see embed.FS for the first time, you may want to ask: “Is this an archive? zip? magic?”. In reality, it is more convenient to think of it like this: embed.FS is a struct that implements the fs.FS interface, i.e., it behaves like a “source of files.” The only difference is that the files come not from disk, but from the binary.

Imagine you have a backpack (the binary), and inside it there is a folder assets/. You cannot “stuff” a new file into it at runtime, but you can open and read what you placed there ahead of time during the build.

One practical idea is important to remember: with embed.FS we almost never work “directly.” We work through functions in the io/fs package, because they can read both real files and embedded ones in the same way.

A small table to lock in the roles:

What we have What is it? How we read
os.DirFS(".")
a “virtual FS,” but backed by a real directory
fs.ReadFile, fs.ReadDir
embed.FS
a “virtual FS,” but inside the binary
fs.ReadFile, fs.ReadDir
*os.File
a specific opened file io.Reader methods (a slightly different level)

2. Practice: reading and debugging embed.FS

First step: embed the directory and read a file

You cannot jump right into reading files from embed.FS: first you need to “bake” them into the binary. For that, the //go:embed directive is used. In this lecture, we assume you have already seen the basic declaration (previous topic) and focus specifically on reading.

Suppose we have a file assets/help.txt in the project. We want to read it and print it to the screen.

package main

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

//go:embed assets/*
var embedded embed.FS

func main() {
	b, err := fs.ReadFile(embedded, "assets/help.txt")
	if err != nil {
		fmt.Println("read help:", err)
		return
	}
	fmt.Print(string(b)) // print the help text
}

Several important things are happening here at once.

First, fs.ReadFile returns []byte, because at the “file” level it is more correct to think in bytes: a file can be text, or it can be binary data. Second, the path "assets/help.txt" is not a “path on disk,” but a path inside the FS (we will return to this separately). Third, you must check the error: “not found” is not exotic; it is the most common problem at the start.

Why we read specifically via io/fs

Beginners sometimes want to ask: “Why doesn’t embed.FS just give me a ReadFile method?”. The answer is Go-style pragmatic: because the standard library already has a universal io/fs layer, and it is specifically designed so that you write code that does not care where the data came from.

This is one of those moments where Go is very consistent: “fewer unique APIs, more shared interfaces.” A similar philosophy appears elsewhere in the standard library—for example, in error handling: errors are values; you can wrap them, pass them around, and check them without turning your code into “exceptions out of thin air.”

In practice it has a great effect: if a function accepts fs.FS, then tomorrow you will be able to read the same resources not from embed, but from a test FS, or from os.DirFS, or from somewhere else—without rewriting the code.

Here is a small “helper” that already looks like a normal library function:

package main

import (
	"fmt"
	"io/fs"
)

func readTextFile(fsys fs.FS, name string) (string, error) {
	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read %q: %w", name, err)
	}
	return string(b), nil
}

Notice %q: it puts the file name in quotes, which makes it easier to spot spaces, slashes, and other surprises in logs/errors.

fs.ReadDir: how to see what exactly got embedded

Next, a classic situation usually happens: you are sure the file is embedded, but fs.ReadFile says “not found.” At this moment, a developer goes through five stages of acceptance, and somewhere between “bargaining” and “depression” remembers: you can just print the file list!

And this is where fs.ReadDir is our best friend. It reads a directory inside fs.FS and returns a list of fs.DirEntry.

package main

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

//go:embed assets/*
var embedded embed.FS

func main() {
	entries, err := fs.ReadDir(embedded, "assets")
	if err != nil {
		fmt.Println("readdir:", err)
		return
	}

	for _, e := range entries {
		fmt.Println(e.Name(), "dir?", e.IsDir()) // help.txt dir? false
	}
}

Why this is useful specifically for learning and debugging.

You immediately see “how exactly” Go placed files inside embed.FS. Sometimes the problem is not in the code, but in the fact that the file is named Help.txt, while you read help.txt. Sometimes you embedded assets/*, but the file is located at assets/docs/help.txt, and it did not get included. fs.ReadDir helps you distinguish “it wasn’t embedded” from “I’m reading it wrong.”

Paths inside embed.FS

Now for an important conversation about paths. It is not complicated, but it is very “sticky”: mix it up once, and you will remember this lecture every time you see file does not exist.

The path you pass to fs.ReadFile(fsys, name) is an FS path, i.e., a path inside the abstraction fs.FS. It is not your operating system path. Therefore:

  • it is usually relative;
  • it uses / as a separator;
  • it must match the structure you actually embedded.

Let’s deliberately make an example of “how to ruin your evening” and “how to fix it.”

package main

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

//go:embed assets/*
var embedded embed.FS

func main() {
	name := filepath.Join("assets", "help.txt") // on Windows this will be "assets\help.txt"
	_, err := fs.ReadFile(embedded, name)
	fmt.Println("err:", err) // err: open assets\help.txt: file does not exist (example)
}

Why does this happen? Because filepath.Join honestly builds a path for the OS, while embed.FS expects an FS path with /. Inside embed.FS there is no concept of “C: drive,” “home directory,” or even “absolute path.” There is only the tree you embedded at build time.

For this lecture, the simplest agreement is: for embed.FS write paths by hand as strings with /, without filepath.Join. This is not a “dirty hack,” it is simply the correct model: we are not working with an OS path, but with a path inside the FS.

4. Embedding help into a learning app

So the examples are not hanging in a vacuum, let’s tie this to our learning app. Let’s say we have a simple console program TaskBox: it reads commands from stdin and, for now, can at least show command help.

Let’s make the help live in assets/help.txt, but be distributed together with the binary.

Suppose the project structure looks like this:

taskbox/
  main.go
  assets/
    help.txt
    banner.txt

In main.go, embed the directory and write a couple of small read functions.

package main

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

//go:embed assets/*
var embedded embed.FS

func printAsset(name string) error {
	b, err := fs.ReadFile(embedded, name)
	if err != nil {
		return fmt.Errorf("read asset %q: %w", name, err)
	}
	fmt.Print(string(b))
	return nil
}

Now we use this in the “interactive” logic. We are not touching command-line arguments yet (that is a separate topic); instead we implement a simple command input loop.

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	_ = printAsset("assets/banner.txt") // if the banner is missing, that's fine; we just don't show it

	in := bufio.NewReader(os.Stdin)
	fmt.Print("> ") // >

	var cmd string
	fmt.Fscan(in, &cmd)

	if cmd == "help" {
		if err := printAsset("assets/help.txt"); err != nil {
			fmt.Println("error:", err)
		}
	}
}

The key technique here is: the app logic does not keep help inside the code as string literals; instead it reads it as an external resource, while that resource still remains “inside the binary.” This approach is great for maintaining large help texts: it is easier to edit them as a regular text file than as a huge string in Go code.

And yes, it also reduces the chance of accidentally forgetting the file during deployment. But it increases the binary size—there is no free lunch. In Go, people periodically bring up the topic of binary sizes and how different decisions affect them.

5. Typical mistakes when reading embedded resources

Mistake #1: reading with the wrong path because you mixed up “what was embedded” and “how you read it.”
A very common story: you embedded assets/*, but you read "help.txt". Or the other way around: you embedded a single file help.txt, but you read "assets/help.txt". embed.FS does not “guess”; it honestly stores names as they came in. If you are unsure, fs.ReadDir is your flashlight in a dark forest.

Mistake #2: using filepath.Join for paths inside embed.FS.
On Linux you might not notice the problem, because the separator there is also /. But on Windows you will quickly get assets\help.txt and the “file does not exist” error. Inside embed.FS there are FS paths, not OS paths, so the simplest approach is to keep strings like "assets/help.txt" as constants and not “glue” them with filepath.

Mistake #3: ignoring err from fs.ReadFile and then wondering why the string is empty.
If the read fails, you will get a nil byte slice and an error. If you ignore the error and do string(b), you will see an empty string and start looking for “encoding problems,” when in reality the file was simply not found. The error is the main diagnostic signal.

Mistake #4: expecting a change in assets/help.txt to update an already built binary.
Embedded resources are fixed at build time. If you changed help.txt but did not rebuild the program, the binary still contains the old version. This is not a bug; it is the point of the approach: “bake it into the binary.” Therefore, during debugging it is often useful to temporarily print len(help) or the build date so you do not chase ghosts.

Mistake #5: reading huge files via fs.ReadFile without realizing it reads the entire file into memory.
fs.ReadFile is convenient, but it reads the whole file into []byte. For help text or a small template, this is perfect. For a gigantic 2 GB CSV, do not do this (and embedding it into the binary is also, to put it mildly, questionable).

1
Task
Go SELF, level 44, lesson 1
Locked
Help sign
Help sign
1
Task
Go SELF, level 44, lesson 1
Locked
File inventory
File inventory
1
Task
Go SELF, level 44, lesson 1
Locked
Universal reader
Universal reader
1
Task
Go SELF, level 44, lesson 1
Locked
Path inside the firmware
Path inside the firmware
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION