skip to content

Why does embed.FS keep the directory prefix in file names, and what does fs.Sub do about it?

level: middleimportance: should knowfreq 48%

answer

  1. the embedded name keeps its directory
  2. one filesystem, one root
  3. the directory is part of the path
  4. re-root instead of trimming strings
  5. it returns a view and an error

basics

~20 s

An embed.FS stores every file under the exact path the pattern matched, so embedding a templates directory gives names like templates/index.html, not index.html. fs.Sub returns a view rooted at a subdirectory, so callers can ask for index.html.

solid answer

~40 s

Names inside any `io/fs` filesystem are unrooted, slash-separated paths relative to that filesystem's root, and `embed.FS` uses the pattern's own path as the name. So `//go:embed templates` gives you an FS whose root contains a single directory called `templates`, and `ReadFile("index.html")` fails while `ReadFile("templates/index.html")` succeeds. That trips people up because on disk they were used to opening the directory first. The fix is `fs.Sub(fsys, "templates")`, which returns a new `fs.FS` — a thin view, not a copy — whose root is that subdirectory; every name it receives gets the prefix added back before it delegates. Doing this once, next to the `//go:embed` line, means the rest of the program never hard-codes the prefix, and swapping in `os.DirFS("templates")` later is a one-line change.

code

go · 10 lines
go
//go:embed templates
var embedded embed.FS // holds "templates/index.html", not "index.html"

func index() ([]byte, error) {
	sub, err := fs.Sub(embedded, "templates")
	if err != nil {
		return nil, err
	}
	return fs.ReadFile(sub, "index.html")
}

go deeper

for a junior

Remember that the name you pass to an embedded filesystem includes the directory you embedded: templates/index.html, not index.html. Getting a does-not-exist error on an obviously present file is usually this.

for a middle

Explain that io/fs names are unrooted and slash-separated relative to the filesystem's root, and that fs.Sub returns a view rooted lower down rather than rewriting names by hand.

for a senior

Show the boundary design: hand the rest of the program an already re-rooted filesystem so no caller hard-codes the prefix, and switching between embedded assets and a directory on disk stays a one-line change.

for a principal

Decide once, for the codebase, where re-rooting happens and what type crosses the package boundary, because that choice determines how much code has to change when the asset layout moves.

## Names in an io/fs filesystem Every filesystem in the `io/fs` world — an `embed.FS`, the value returned by `os.DirFS`, anything you write yourself — uses the same naming rules, and they are not the operating system's rules. A name is a sequence of slash-separated elements, always forward slashes even on Windows, always relative to the root of that filesystem, never beginning or ending with a slash, and never containing a `.` or `..` element. The one special case is `.`, which names the root itself. `fs.ValidPath` is the helper that checks this, and an implementation is expected to return an error for any name that fails it. The important consequence: a name is meaningless without knowing which filesystem it belongs to. `index.html` is a different file depending on where the root is. ## Why the prefix is there When the toolchain processes `//go:embed templates`, it records each matched file under the path that matched, relative to the package directory. The directory element is part of that path. So the resulting `embed.FS` has a root that contains one entry, the directory `templates`, and inside it `index.html`, `layout.html`, and so on. This is not an accident of implementation; it is what makes multiple patterns composable. A single variable can carry `//go:embed templates` and `//go:embed static`, and the two trees sit side by side under distinct names without any chance of `templates/index.html` colliding with `static/index.html`. If embedding a directory silently stripped its name, two patterns could produce two different files with the same name and there would be no rule for which wins. The cost is the surprise: the developer typed the directory once in the directive, so it feels as though the FS *is* that directory, and the first `ReadFile("index.html")` returns a `file does not exist` error. ## What fs.Sub does `fs.Sub(fsys fs.FS, dir string) (fs.FS, error)` returns a filesystem whose root is `dir` inside `fsys`. Three properties matter: - **It copies nothing.** The returned value is a small wrapper holding the original filesystem and the prefix. Every operation joins the prefix onto the requested name and delegates. There is no I/O and no allocation of file contents. - **It validates the name, not its existence.** `dir` must satisfy `fs.ValidPath`, so `"/templates"` and `"templates/"` are errors. But `fs.Sub` does not check that the directory is actually present — a wrong-but-valid name gives you a filesystem in which every lookup fails later. - **It respects optional interfaces.** If the underlying filesystem implements `fs.SubFS`, `fs.Sub` calls its own `Sub` method and lets the implementation do something smarter; otherwise it uses the generic wrapper. The same delegation pattern appears throughout `io/fs`. ## Where to put the call The idiomatic placement is immediately beside the embed declaration, so that the prefix appears exactly once in the codebase: Declare the raw `embed.FS`, unexport it, and export or pass around only the re-rooted view. Now the rest of the program talks in names like `index.html` and knows nothing about how the files arrive. That decoupling is what makes it a one-line change to load from `os.DirFS("./templates")` during local iteration instead of from the binary, and it is why a package that accepts an `fs.FS` parameter is more useful than one that accepts a directory string. The alternative people reach for first — trimming or prepending the prefix with string operations at each call site — works, but it scatters the same literal through the codebase and breaks the moment the asset directory is renamed or nested one level deeper. ## A related trap Because names are always slash-separated, building them with the operating system's separator is wrong on Windows: a name containing a backslash is simply a name with an odd character in it, not a path with an element boundary. Build filesystem names by concatenating with slashes, or with the `path` package's join, and keep OS-specific path handling to the code that talks to the operating system directly.

  • Does fs.Sub copy the files into a new filesystem?
    No. It returns a thin wrapper that holds the original filesystem and the directory prefix, and joins the prefix onto every name before delegating. No file contents are read or duplicated, so it is cheap enough to call at startup or per request. If the underlying value implements `fs.SubFS`, its own `Sub` method is used instead of the generic wrapper.
  • What does fs.Sub do if you pass "/templates" or "templates/"?
    It returns an error. The directory argument must satisfy `fs.ValidPath`: unrooted, slash-separated, no leading or trailing slash and no `.` or `..` elements. Note that it validates the *shape* of the name only — it does not check that the directory exists, so a valid but wrong name produces a filesystem whose every lookup fails later.
  • How would you let the same code read from the binary or from a directory on disk?
    Have the code take an `fs.FS`. Pass `fs.Sub(embedded, "templates")` in the shipped build and `os.DirFS("templates")` when iterating locally; both roots then contain `index.html` directly, so no call site changes. Keeping the re-rooting in one place next to the embed declaration is what makes that substitution a single line.

saying these in an interview costs you the question

  • Trims the prefix with string operations at every call site
  • Expects index.html to resolve after embedding a whole directory
  • Thinks fs.Sub copies files into a new filesystem
  • Builds embedded file names with the OS separator
  • Assumes fs.Sub errors when the directory does not exist