8.14 io/fs — Senior¶
Audience. You've built code around
fs.FS, written your ownFSonce, and shipped a service that depends on the abstraction. This file is the precise contract: whatOpenis allowed to do, whatValidPathpermits, whereos.DirFSquietly fails on symlinks, whatos.Root(Go 1.24) was added to fix, the symlink support added in Go 1.21, and the small set of edge cases that bite when an FS becomes a security boundary.
1. The exact fs.FS contract¶
The package documentation:
An FS provides access to a hierarchical file system. The FS interface is the minimum implementation required of the file system. A file system may implement additional interfaces, such as ReadFileFS, to provide additional or optimized functionality.
The contract on Open:
namemust satisfyfs.ValidPath. If not,Openshould return a*PathErrorwhoseErrisfs.ErrInvalid. The standard library does this universally; user-written FSes that skip the check are technically non-conformant.- The returned
Fileis not yet read.Readon a freshly opened file produces the file's first byte, not an arbitrary midpoint. - Two
Opencalls on the same name return independent files. They have independent positions, can be read concurrently, and close independently. (Whether the underlying bytes are shared is implementation-defined; the file values aren't.) Open(".")is the FS root. It must return aFilewhoseStatreportsIsDir() == trueand which implementsReadDirFile. Without this,fs.WalkDir(fsys, ".", ...)doesn't work.
Implementations may return additional capabilities by satisfying optional interfaces (ReadFileFS, ReadDirFS, StatFS, GlobFS, SubFS, ReadLinkFS). Optional interfaces are additive: they can speed up an operation but never change its semantics.
2. ValidPath: what it permits, exactly¶
Source-of-truth implementation, paraphrased:
// ValidPath reports whether the given path name
// is valid for use in a call to Open.
//
// Path names passed to open are UTF-8-encoded,
// unrooted, slash-separated sequences of path elements,
// like "x/y/z". Path names must not contain an element
// that is "." or ".." or the empty string, except for
// the special case that the root directory is named ".".
// Paths must not start or end with a slash: "/x" and "x/"
// are invalid.
//
// Note that paths are slash-separated on all systems,
// even Windows. Paths containing other characters such
// as backslash and colon are accepted as valid, but
// those characters must never be interpreted by an FS
// implementation as path element separators.
The full set of rejections:
| Input | Reject reason |
|---|---|
"" | Empty |
"/" or "/x" | Leading slash |
"x/" | Trailing slash |
"x//y" | Empty element |
"./x" or "x/." | . element |
"../x" or "x/.." or "a/../b" | .. element |
Permitted:
| Input | Notes |
|---|---|
"." | The root, the only . allowed |
"x" | Single segment |
"x/y" | Multiple segments |
"a\\b" | Allowed: backslash is not a separator in this universe |
"foo:bar" | Allowed |
| Any UTF-8 string subject to the rules | Allowed, including spaces |
UTF-8 is required but not validated — ValidPath does not check that the name decodes cleanly. An FS that wants to enforce well-formed UTF-8 has to do that itself.
3. The File contract¶
Each method:
Statreturns metadata. May be called multiple times. May return the sameFileInfovalue each time (it's read-only from the caller's perspective).Readfollows theio.Readercontract from ../01-io-and-file-handling/senior.md: short reads are legal, EOF can come with data,(0, nil)is discouraged. AfterClose,Readshould return an error wrappingfs.ErrClosed.Closereleases resources. AfterClose, all subsequent methods should return errors wrappingfs.ErrClosed. Behavior of double-Closeis implementation-defined; conventionally the secondClosereturns an error wrappingfs.ErrClosed.
The optional io.Seeker is not required. A file from embed.FS or fstest.MapFS happens to implement it (because it's backed by a bytes.Reader), but a file from a streaming source (a pipe-style zip entry) may not. Don't assume.
4. The ReadDirFile contract¶
A directory entry is also a File, but its Read is not useful; instead it implements ReadDirFile:
Semantics of the n argument:
n > 0: return up tonentries. If fewer remain, return what's there andio.EOFon the next call (or together — same EOF ambiguity asio.Reader).n <= 0: return all remaining entries. Subsequent calls returnnil, nil(ornil, io.EOF— both are acceptable).
The entries should be in some order; the standard library implementations all return them in lexicographical order, and fs.ReadDir (the helper) sorts the result anyway. So if you return them unsorted, fs.ReadDir still gives sorted output, but direct callers of ReadDir(-1) see whatever you produce.
5. The five sentinel errors¶
var (
ErrInvalid = errors.New("invalid argument")
ErrPermission = errors.New("permission denied")
ErrExist = errors.New("file already exists")
ErrNotExist = errors.New("file does not exist")
ErrClosed = errors.New("file already closed")
)
These are the same values as the corresponding os errors. In Go 1.16+, os.ErrNotExist == fs.ErrNotExist (both point to the same *errorString). That means errors.Is(err, fs.ErrNotExist) works whether the error originated in os or in io/fs, and similarly for the others.
What they mean:
| Error | When |
|---|---|
ErrInvalid | The arguments were syntactically invalid (bad name) |
ErrPermission | The OS or implementation denied access |
ErrExist | A create-style operation found the file already there (less relevant here since io/fs is read-only) |
ErrNotExist | The named file isn't in the FS |
ErrClosed | A method was called after Close |
The wrapper type:
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
func (e *PathError) Unwrap() error { return e.Err }
*PathError is the same type as *os.PathError (alias in Go 1.16+). errors.Is(err, fs.ErrNotExist) traverses the Unwrap chain correctly.
6. os.DirFS does not prevent path escape¶
The most-tested footgun. os.DirFS(root) creates an fs.FS rooted at root. The names you pass are validated by fs.ValidPath, which rejects .. and absolute paths. So far so good.
But os.DirFS resolves names by concatenating with root and calling os.Open on the result. If anything along the way is a symlink whose target is outside root, the OS happily follows it.
fsys := os.DirFS("/var/www")
// Suppose /var/www/uploads/foo is a symlink to /etc/passwd.
data, _ := fs.ReadFile(fsys, "uploads/foo")
// data == contents of /etc/passwd
For a server that stores user uploads in /var/www/uploads, an attacker who controls a single symlink wins. os.DirFS is name-safe, not symlink-safe.
The package documentation says exactly this:
Note that DirFS("/prefix") only guarantees that the Open calls it makes to the operating system will begin with "/prefix": DirFS("/prefix").Open("file") is the same as os.Open("/prefix/file"). So if /prefix/file is a symbolic link pointing outside the /prefix tree, then using DirFS does not stop the access any more than using os.Open does. Additionally, the root of the fs.FS returned for a relative path, DirFS("prefix"), will be affected by later calls to Chdir.
The fix arrived in Go 1.24.
7. os.Root and os.OpenRoot (Go 1.24+)¶
*os.Root is a confined filesystem handle. Symlinks that resolve outside the root, .. segments at the OS level, absolute paths in syscalls — all rejected.
root, err := os.OpenRoot("/var/www")
if err != nil { return err }
defer root.Close()
f, err := root.Open("uploads/foo") // refuses to escape /var/www
Under the hood, os.Root uses platform-specific syscalls (openat with O_NOFOLLOW-like semantics on Linux, AT_NO_AUTOMOUNT and friends, equivalents on other platforms) to ensure each path component is resolved within the root.
(*os.Root).FS() returns an fs.FS view of the root:
For new code that takes user-supplied paths, prefer os.OpenRoot over os.DirFS. For code that doesn't take user paths (your own assets, your own templates), os.DirFS is fine.
The relationship:
| Use case | Pre-1.24 | 1.24+ |
|---|---|---|
| Your own asset directory | os.DirFS | os.DirFS |
| User-supplied path under your root | Manual validation, never quite right | os.OpenRoot then .FS() |
fs.FS for testing | os.DirFS | os.DirFS |
8. Symlink support: Go 1.21+¶
Before Go 1.21, io/fs had no symlink concept. Stat followed symlinks (returning info about the target); there was no Lstat or ReadLink in the abstraction. Go 1.21 added ReadLinkFS:
// Go 1.21+
type ReadLinkFS interface {
FS
ReadLink(name string) (string, error)
Lstat(name string) (FileInfo, error)
}
And the package-level helpers:
fs.ReadLink(fsys fs.FS, name string) (string, error)
fs.Lstat(fsys fs.FS, name string) (FileInfo, error)
If fsys implements ReadLinkFS, the helpers call its methods. Otherwise:
fs.Lstatfalls back tofs.Stat(which follows symlinks). On an FS that doesn't expose symlinks, the two are the same.fs.ReadLinkreturns an error indicating the FS doesn't support symlinks.
A FileInfo whose Mode() includes fs.ModeSymlink is a symlink. os.DirFS (Go 1.21+) implements ReadLinkFS. embed.FS does not (symlinks aren't embedded). fstest.MapFS implements it for testing, where you can set Mode: fs.ModeSymlink on a MapFile and put the target in Data.
9. The exact WalkDir algorithm¶
Pseudocode:
info, err := Stat(fsys, root)
if err != nil {
err = fn(root, nil, err)
} else {
err = walkDir(fsys, root, FileInfoToDirEntry(info), fn)
}
if err == SkipDir || err == SkipAll {
return nil
}
return err
walkDir(fsys, name, d, fn):
if err := fn(name, d, nil); err != nil {
if d.IsDir() && err == SkipDir { return nil }
return err
}
if !d.IsDir() { return nil }
entries, err := ReadDir(fsys, name)
if err != nil {
// Second call to fn with the error.
err = fn(name, d, err)
if err != nil { return err }
}
for _, e := range entries {
sub := name + "/" + e.Name() // approximately; uses path.Join
if err := walkDir(fsys, sub, e, fn); err != nil {
if err == SkipDir { break }
return err
}
}
return nil
The subtle parts:
fnmay be called twice for a directory — once before reading its contents, once again with an error ifReadDirfails.SkipDirreturned from a non-directory has the effect of skipping the rest of the parent directory. (This is sometimes confusing; many callers only return it from directories.)SkipAll(Go 1.20+) terminates the entire walk and is converted to anilreturn fromWalkDir.- Symlinks are not followed. If you want symlink-following walks, do it yourself with
ReadLink.
10. WalkDir vs filepath.Walk: why WalkDir is faster¶
The older filepath.Walk calls fn with a FileInfo for every file. To produce that FileInfo, it has to Stat every entry even if the caller never inspects the size or mtime.
WalkDir passes a DirEntry instead. DirEntry.Name(), IsDir(), and Type() come for free from the parent directory's ReadDir result; Info() is deferred. For walks that filter by extension or prune by directory name, no extra syscalls happen.
On a tree with 100,000 files where the walker only inspects filenames, WalkDir is roughly twice as fast as Walk on typical Linux filesystems. The exact ratio depends on the FS type and how cold the cache is. For embedded data and MapFS, both are instantaneous.
11. Concurrency¶
Nothing in io/fs makes concurrency claims. The interfaces don't mention it. Each implementation decides:
os.DirFS: eachOpenreturns a fresh*os.File. Two goroutines opening the same name get independent files. Reading the same*os.Filefrom two goroutines races (same caveats as always; see ../01-io-and-file-handling/senior.md).embed.FS: thread-safe forOpen,ReadFile,ReadDir,Stat. Returnedfs.Filevalues are not safe for concurrentReadfrom two goroutines (independent goroutines reading separately opened files are fine).fstest.MapFS: not safe to mutate while reading. Read-only use is fine.
A custom FS author should treat the FS itself as concurrent-safe (many goroutines calling Open, ReadFile, etc.) and the returned File as single-goroutine. That matches the standard library and is the least surprising contract.
12. The path package, not filepath¶
When you implement an FS, every path manipulation uses the path package, not path/filepath:
| Need | Use |
|---|---|
| Join two FS path segments | path.Join |
| Get parent | path.Dir |
| Get base name | path.Base |
| Match a glob | path.Match |
| Clean | path.Clean |
path/filepath is OS-aware (uses backslashes on Windows). path is for forward-slash virtual paths — the universe io/fs lives in. Mixing them produces "no such file" errors that look like bugs in the FS.
If you build an FS that bridges to disk (a tar reader, a zip reader), keep the path operations on the FS side and any filepath operations on the disk side, with filepath.ToSlash and filepath.FromSlash as bridges.
13. The embed.FS zero value¶
var fsys embed.FS // zero value
fs.ReadFile(fsys, "anything") // returns *fs.PathError wrapping fs.ErrNotExist
A zero embed.FS is a valid empty FS — every file lookup returns fs.ErrNotExist. Useful as a sentinel: a function that conditionally embeds can use a zero embed.FS for the "no embedded assets" case without a nil check.
fstest.MapFS zero value is also a valid empty FS (it's a nil map, which acts like an empty map for reads).
14. Optional interface assertions: when they happen¶
The package-level helpers do type assertions to find optional interfaces. The cost is one type assertion per call (cheap, but non-zero). If you call fs.ReadFile(fsys, name) in a hot loop and fsys is the same value each iteration, the type assertion happens every time.
For very hot paths, type-assert once and reuse:
rf, ok := fsys.(fs.ReadFileFS)
for _, name := range names {
var data []byte
var err error
if ok {
data, err = rf.ReadFile(name)
} else {
data, err = fs.ReadFile(fsys, name)
}
// ...
}
This is rarely worth the extra code; the type assertion is faster than nearly any operation it gates.
15. http.FS vs http.FileServerFS¶
http.FS(fsys fs.FS) http.FileSystem is the older adapter, from Go 1.16 when io/fs was introduced. http.FileServerFS(fsys fs.FS) http.Handler is from Go 1.22 — same idea, one less call.
Either is fine. The Go 1.22 version is conventional in new code:
Behind the scenes, http.FileServerFS calls http.FileServer(http.FS(fsys)). The behavior is identical: directory listings if no index.html, content-type sniffing, range requests, conditional GETs.
16. template.ParseFS errors and how to read them¶
The argument is one or more glob patterns. Each pattern must match at least one file; if any pattern matches nothing, ParseFS returns:
This is a frequent source of "but the file is right there" debugging. Causes:
- The file's path in the FS includes a directory prefix that the pattern doesn't account for. (
embed.FSkeeps thetemplates/prefix;fs.Subremoves it.) - The FS is empty (zero
embed.FS, emptyMapFS). - The pattern uses
**(not supported) or assumes recursive glob. - The file has a
.or_prefix andembed.FSwas built withoutall:.
Run fs.WalkDir(fsys, ".", func(p string, _ fs.DirEntry, _ error) error { fmt.Println(p); return nil }) to dump what the FS thinks is in it. If your file isn't there, the problem is upstream.
17. fs.FormatFileInfo and fs.FormatDirEntry (Go 1.21+)¶
Two helpers for human-readable formatting:
info, _ := fs.Stat(fsys, "go.mod")
fmt.Println(fs.FormatFileInfo(info))
// output like: -rw-r--r-- 1234 May 5 14:30 go.mod
Useful in CLIs, log output, and debugging. The format matches ls -l roughly. Don't parse it — it's for humans.
18. os.DirFS and relative roots¶
A relative root is resolved against the current working directory at each call. If the program calls os.Chdir between os.DirFS and Open, the FS shifts under it. The package documentation calls this out as a known sharp edge.
Practical advice: pass an absolute path to os.DirFS, or convert once at startup:
19. The Sys() method: implementation-specific data¶
Sys is the escape hatch. Different implementations stash different things:
os.FileInfo.Sys()returns*syscall.Stat_ton Unix-like systems,*syscall.Win32FileAttributeDataon Windows. You can read uid/gid/inode from it if you really need to.embed.FSfiles returnnilfromSys()— there's no underlying OS data.fstest.MapFSreturns whatever you put inMapFile.Sys.
A function that wants to be portable across FS types should not depend on Sys(). Use it only when you've narrowed the source to a known concrete type.
20. Read-only by design¶
io/fs has no Write, no Create, no Mkdir, no Remove. The abstraction is read-only and there is no plan to extend it.
The reasoning, articulated in the design discussion (Russ Cox, 2020): a write API has to address atomicity, durability, locking, and crash safety — all of which are filesystem-specific in ways a single interface couldn't paper over without lying. Read access is comparatively benign: a missing file is a missing file everywhere; a permission error is a permission error everywhere.
If you want to write, you're in os or in custom-implementation territory. The community has occasionally proposed WriteFS-style interfaces (e.g., the hackpadfs library); none have made it into the standard library, and none seem likely to.
21. Reading: what to read next¶
- professional.md — production patterns for
fs.FS-based services. - specification.md — the interface reference in compact form.
- find-bug.md — drills targeting the items in this file.
../01-io-and-file-handling/senior.md— theio.Reader/io.Writercontracts thatfs.Filebuilds on.- The official package docs:
io/fs.
External references:
- Russ Cox, "io/fs: add filesystem interface" (golang.org/issue/41190) — the original design discussion.
- The Go 1.24 release notes, "os" — the introduction of
os.OpenRootand the rationale.