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.
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 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.
Cost: Open returns an fs.File, which is an interface, so every call costs an interface method dispatch. For high-volume reads this is fine because the disk I/O dominates, but in a tight loop over thousands of small in-memory files the dispatch cost can be measurable.
fs.FS paths are not OS paths. The package documents a precise format:
"catalog/products.json", not "/catalog/products.json"../. "products.json", not "./products.json"... segments. The path cannot escape the root."." 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.
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:
| Interface | Method | Helper Function | Purpose |
|---|---|---|---|
fs.FS | Open(name) (File, error) | (the base interface) | Open one file by path |
fs.ReadDirFS | ReadDir(name) ([]DirEntry, error) | fs.ReadDir(fsys, name) | List entries in a directory |
fs.StatFS | Stat(name) (FileInfo, error) | fs.Stat(fsys, name) | Get metadata without opening |
fs.ReadFileFS | ReadFile(name) ([]byte, error) | fs.ReadFile(fsys, name) | Read a whole file in one call |
fs.GlobFS | Glob(pattern) ([]string, error) | fs.Glob(fsys, pattern) | Match paths against a glob pattern |
fs.SubFS | Sub(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.
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.
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.
fs.Stat returns an fs.FileInfo without opening the file for reading. Use it when you only need size, mode, or modification time.
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.
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.
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.
The two look nearly identical, but they differ in two important ways:
| Aspect | fs.WalkDir | filepath.WalkDir |
|---|---|---|
| Source | Any fs.FS | The real OS filesystem |
| Path separator | Always / | OS-native (\ on Windows, / elsewhere) |
| Root path argument | An fs.FS path | An 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(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.
Cost: os.DirFS doesn't cache anything. Every Open performs an openat syscall, every Stat performs a fstatat. For repeated reads of the same file, layer a cache or read the file once into memory.
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.
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.
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 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.
Cost: Searching N sources costs up to N reads when the file is missing everywhere. If the lookup happens on a hot path, cache the resolved location after the first successful find.
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:
fs.FS is read-only by design. There's no Create or Write. Output paths stay on os.WriteFile or os.Create.fs.FS.os.File.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.
10 quizzes