AlgoMaster Logo

fs.FS Interface

Low Priority19 min readUpdated June 6, 2026

Before Go 1.16, packages that read files used os.Open directly. That worked, but it tied the code to a real disk. Tests had to create temp directories, code that needed embedded assets had to use a separate API, and there was no clean way to plug in an in-memory filesystem for fast tests or a virtual filesystem for a tool. The io/fs package, introduced in Go 1.16, fixed this by turning "a filesystem" into a small interface that any source of files can satisfy: a real directory on disk, an embed.FS of compiled-in assets, a fstest.MapFS of in-memory test data, a zip archive, or anything else.

Why fs.FS Exists

Consider a product-catalog loader that reads JSON files from a directory. The first version uses os.ReadFile directly:

This loader works, but it has a problem that only becomes visible at test time. To test it, you need a real catalog/ directory with real files on real disk. That means temp dirs, file cleanup, and tests that touch the filesystem on every run. It also means the same loader can't be reused for assets compiled into the binary (with //go:embed) without rewriting it against a different API.

The fix is to stop hardcoding "the disk" as the source of files. Instead, accept anything that looks like a filesystem:

Now fsys can be a real directory wrapped with os.DirFS, an in-memory fstest.MapFS, an embed.FS, or any future filesystem-like thing somebody writes. The loader doesn't care. It just calls methods on the interface.

The io/fs abstraction lets file-reading code stop being coupled to disk.

The fs.FS Interface

The core interface is the smallest one Go could reasonably ship:

One method. Given a path, return something readable. That's it.

The File interface is almost as small:

A file knows its metadata (Stat), it can be read from (Read), and it must be closed (Close). Anything that supports those three operations qualifies. That includes real OS files, in-memory buffers wrapping a byte slice, gzip-decompressed streams, anything.

The minimal interface is deliberate. By keeping fs.FS to one method, almost any source of files can satisfy it cheaply. The richer behaviors (listing directories, walking trees, globbing) live in optional extension interfaces, which is the next topic.

Here's a tiny custom fs.FS implementation that serves a single file from memory, just to make the interface concrete:

Twenty lines of boilerplate to serve one file from memory. In practice nobody writes their own fs.FS like this, because the standard library and fstest.MapFS already cover the common cases. The example exists to show that the interface really is that small.

Path Rules

fs.FS paths are not OS paths. The package documents a precise format:

  • Forward slashes always, even on Windows.
  • No leading slash. "catalog/products.json", not "/catalog/products.json".
  • No leading ./. "products.json", not "./products.json".
  • No .. segments. The path cannot escape the root.
  • Use "." to refer to the root itself.

These rules exist so that the same path string means the same thing on every operating system and against every fs.FS backend. If your code stores paths to look them up later, you can rely on the format being uniform.

When you implement an fs.FS, the standard helper fs.ValidPath(name) reports whether a given path follows the rules. Callers expect implementations to return an fs.PathError for invalid paths:

The contrast with the path/filepath package is sharp. filepath.WalkDir produces OS-native paths (backslashes on Windows). fs.WalkDir always produces forward-slash paths because the fs.FS interface mandates that format. If you mix the two, you'll get bugs on Windows.

Extension Interfaces

A bare fs.FS only knows how to open files. Most real code wants more: list a directory, stat a path without opening it, read a whole file in one call, glob for patterns, or take a subtree as its own filesystem.

The standard library handles this with optional extension interfaces. An implementation can satisfy as many as it wants. For each one, the io/fs package provides a helper function that uses the extension interface when present and falls back to the base fs.FS otherwise. Callers should use the helper functions, not test for the interface themselves.

This is the chapter's signature table:

InterfaceMethodHelper FunctionPurpose
fs.FSOpen(name) (File, error)(the base interface)Open one file by path
fs.ReadDirFSReadDir(name) ([]DirEntry, error)fs.ReadDir(fsys, name)List entries in a directory
fs.StatFSStat(name) (FileInfo, error)fs.Stat(fsys, name)Get metadata without opening
fs.ReadFileFSReadFile(name) ([]byte, error)fs.ReadFile(fsys, name)Read a whole file in one call
fs.GlobFSGlob(pattern) ([]string, error)fs.Glob(fsys, pattern)Match paths against a glob pattern
fs.SubFSSub(dir) (FS, error)fs.Sub(fsys, dir)View a subtree as its own FS

The helper functions are the right way to call these. fs.ReadDir(fsys, "catalog") works against any fs.FS. If the implementation provides its own ReadDir, the helper uses it directly (likely faster). If not, the helper falls back to opening the directory and reading entries manually. The caller's code is unchanged either way.

Listing a Directory

os.DirFS(".") wraps the current directory as an fs.FS. fs.ReadDir lists the root of that filesystem. Each fs.DirEntry has Name(), IsDir(), Type(), and an Info() method for fuller metadata. DirEntry is the lightweight version, FileInfo is the heavier one with size and mode bits.

The distinction matters for performance: when you walk a large directory and only need names, DirEntry avoids the per-entry Stat call that FileInfo would require.

Reading a Whole File

fs.ReadFile is the fs.FS counterpart to os.ReadFile. The signature differs by an extra fs.FS argument, but the behavior is the same: open, read all, close, return the bytes.

Stat Without Opening

fs.Stat returns an fs.FileInfo without opening the file for reading. Use it when you only need size, mode, or modification time.

Glob

The pattern syntax matches path.Match: * matches any non-slash sequence, ? matches one character, [abc] matches a character class. There is no ** for recursive matching, so glob is one level deep. For recursive listing, use fs.WalkDir.

Sub

fs.Sub takes an fs.FS and a subdirectory, and returns a new fs.FS rooted at that subdirectory. Code that receives the sub-filesystem cannot see anything outside it:

fs.Sub(root, "catalog") gives you an fs.FS whose root is the catalog/ directory. Paths into it start at ., not at catalog/. This is useful for passing a subtree to a library function that expects to own the whole filesystem, without granting access to siblings.

fs.WalkDir

Walking a directory tree is common enough to deserve its own helper. fs.WalkDir visits every file and directory under a starting path, calling a function for each:

The walk function receives the path, the DirEntry, and any error encountered while descending. Returning fs.SkipDir from inside a directory skips the rest of that directory. Returning fs.SkipAll (added in Go 1.20) stops the entire walk. Returning a non-nil error stops the walk and returns that error from fs.WalkDir.

fs.WalkDir vs filepath.WalkDir

The two look nearly identical, but they differ in two important ways:

Aspectfs.WalkDirfilepath.WalkDir
SourceAny fs.FSThe real OS filesystem
Path separatorAlways /OS-native (\ on Windows, / elsewhere)
Root path argumentAn fs.FS pathAn OS path

If you're walking real disk and want OS paths back, use filepath.WalkDir. If you're walking any abstract filesystem (including embed.FS or test data), use fs.WalkDir. The slash-only path rule comes from the fs.FS interface contract, so it applies to every walk done through io/fs.

os.DirFS and Disk

os.DirFS(root) wraps a directory on disk as an fs.FS. It's the bridge from the real filesystem into the io/fs world:

There's a subtle caveat. Before Go 1.20, the filesystem returned by os.DirFS did not strictly prevent escapes through symlinks: a symlink inside the root could point outside, and reads would follow it. Go 1.20 tightened the semantics so that operations on os.DirFS no longer follow symlinks out of the root. In Go 1.24, the os.Root API arrived as a proper sandbox primitive: os.OpenRoot(dir) gives you a handle through which all relative paths are restricted to that directory, with kernel-level guarantees on Linux. If you need real isolation (for example, serving user-uploaded content), prefer os.Root over os.DirFS. Use os.DirFS when you just want to feed a directory into code that takes an fs.FS.

The implementation of os.DirFS satisfies several extension interfaces (ReadDirFS, StatFS, ReadFileFS), so the helper functions take the fast path when used against it.

Testing with fstest.MapFS

testing/fstest.MapFS is an in-memory fs.FS backed by a Go map. It's the testing counterpart to os.DirFS: same interface, no disk, no cleanup, fast.

fstest.MapFS implements fs.FS, fs.ReadDirFS, fs.StatFS, fs.ReadFileFS, fs.GlobFS, and fs.SubFS. Anything that takes a generic fs.FS works against it without modification. Setting up test data is as cheap as constructing a map literal.

The big win shows up when the same loader code runs against both backends. The next section makes that concrete.

The Payoff: One Loader, Two Backends

Here's the catalog loader rewritten against fs.FS, then exercised once with disk and once with in-memory test data. The loader code is identical in both cases. Only the filesystem changes:

LoadCatalog was written once. It works against an in-memory map and against a real temp directory with no changes. Production code passes os.DirFS(catalogRoot). Tests pass fstest.MapFS{...} with whatever fixtures the test needs. If tomorrow the team wants to embed the default catalog into the binary using //go:embed, that produces an embed.FS, which also satisfies fs.FS. The loader doesn't need to change.

That's the payoff. Decoupling code from "the disk" via a one-method interface lets you test without I/O, ship assets inside the binary, and unify code paths that used to need separate APIs.

Consumers in the Standard Library

The fs.FS interface caught on quickly because the standard library was updated to accept it where it makes sense. Three concrete examples:

  • template.ParseFS(fsys, patterns...) parses HTML or text templates from any fs.FS. The same API works against a disk directory during development and an embed.FS in a shipped binary.
  • http.FileServerFS(fsys) (added in Go 1.22) serves files over HTTP from any fs.FS. Before 1.22, the equivalent was http.FS(fsys) wrapped in http.FileServer.
  • text/template.ParseFS and html/template.ParseFS mirror each other so both template flavors share the abstraction.

A small static-content server reading templates from an abstract fs.FS:

template.ParseFS accepts any fs.FS. In tests it reads from fstest.MapFS. In production it reads from embed.FS or os.DirFS. The template-rendering code doesn't change with the backend.

The same is true for the HTTP file server. A single line difference between development (live-reload from disk) and production (assets embedded in the binary):

The handler doesn't know or care which one is wired up.

A Config Resolver Across Multiple fs.FS Sources

A common pattern is to search several places for a config file: an embed.FS of defaults, a per-user directory, and a project directory. Programming against fs.FS makes this a few lines:

FindConfig walks the sources in order, returning the first one that has the file. The project overrides are empty, the user overrides supply theme=dark, so that's what wins over the defaults. Each "source" is just an fs.FS. In a real program the sources would mix an embed.FS for defaults with os.DirFS for user and project directories. The function is unchanged.

The careful errors.Is(err, fs.ErrNotExist) check matters because fs.FS implementations report missing files as fs.ErrNotExist. Other errors (permissions, corrupted data) propagate. This is the standard error convention for the package: fs.ErrNotExist, fs.ErrPermission, fs.ErrInvalid, all checkable with errors.Is.

When fs.FS Pays Off and When It Doesn't

Programming against fs.FS is the right default when code reads files. It costs nothing extra to write, makes tests faster, and keeps the door open for embedding or alternative backends. The cases where the abstraction adds friction:

  • Code that needs to write files: fs.FS is read-only by design. There's no Create or Write. Output paths stay on os.WriteFile or os.Create.
  • Code that needs filesystem operations beyond read: rename, remove, chmod, symlink. These aren't part of fs.FS.
  • Code that has to call OS-specific syscalls (locking, file watching, advisory locks): stick with os.File.
  • Very hot read paths against a known backend: if you know it's always os.DirFS and you measured a bottleneck, the interface dispatch can be a few percent of overhead.

For the read path, which is most of what file I/O does in application code, the abstraction wins.

The shape of the diagram is the shape of the abstraction. Many concrete backends feed into one interface, which many consumers read from. Adding a new backend (a custom fs.FS for S3, say) lights up every consumer that already speaks fs.FS. Adding a new consumer (a static-site generator that takes an fs.FS) works against every backend without changes.

Quiz

fs Package Quiz

10 quizzes