23.8 fs.FS: The Abstract File System Interface
Right, let’s talk about fs.FS. You’ve probably been knee-deep in os.Open and ioutil.ReadAll (or its modern equivalents) for so long that the idea of a filesystem interface sounds either obvious or like academic nonsense. Trust me, it’s the former, and it’s one of the best ideas Go has had in years. It solves a problem you didn’t know you had: being locked into the actual OS filesystem.
Think of fs.FS as a contract. It’s an interface that says, “I don’t care where your files live—on a disk, in memory, in a ZIP file, or on the moon. If you can give me a way to open a file by name and read it, you fulfill the contract.” This abstraction is the secret sauce that makes text/template or html/template able to read from your hard drive or an embedded set of files without changing a line of their code. They just take an fs.FS.
The interface itself is almost comically simple, which is how you know it’s brilliant.
type FS interface {
Open(name string) (File, error)
}
Seriously. That’s it. The File it returns is another simple interface (io.Reader, io.Seeker, io.Closer, and a couple of others). This minimalism means it’s dead simple to implement your own filesystem for any data source you can imagine.
The Built-in Power: os.DirFS and testing/fstest
You don’t have to build one from scratch to see the benefit. The os package gives you os.DirFS, which takes a root directory path and returns an fs.FS that is… well, a view of that directory. This is your gateway drug.
import (
"fmt"
"io"
"os"
)
func main() {
myFS := os.DirFS("./website/static") // Creates an fs.FS rooted at ./website/static
f, err := myFS.Open("css/main.css")
if err != nil {
panic(err)
}
defer f.Close()
css, _ := io.ReadAll(f)
fmt.Printf("The CSS is %d bytes of pure art.\n", len(css))
}
Now, the killer feature. How do you test code that uses an fs.FS without writing a bunch of messy, temporary files on disk? You use testing/fstest. This package lets you describe an entire in-memory filesystem in a literal map. It’s testing heaven.
import (
"testing"
"testing/fstest"
)
func TestReadConfig(t *testing.T) {
// Create a mock filesystem for your test
mockFS := fstest.MapFS{
"config/prod.yaml": {
Data: []byte("database: postgres://prod@localhost/prod"),
},
"config/dev.yaml": {
Data: []byte("database: sqlite://./dev.db"),
},
}
// Pass mockFS, which implements fs.FS, to your function
prodConfig, err := readConfig(mockFS, "config/prod.yaml")
if err != nil {
t.Fatalf("Failed to read prod config: %v", err)
}
// ... run your assertions on prodConfig ...
}
No temp dirs, no cleanup, no I/O slowdown. Your tests run at lightning speed. This alone is worth the price of admission.
The Gotchas: It’s an Abstract Tree
Here’s the first thing that will bite you: an fs.FS is an immutable tree. Not a graph. A tree. This has critical implications.
- No writes. The interface only defines reading. If you need to write, you’ll need to drop down to
os-specific calls. This makes sense for its primary use cases: serving static assets, reading templates, loading configs. - No
..or symlinks. TheOpenmethod does not allow a name containing..to escape the root of the tree. If you haveos.DirFS("/etc"), trying toOpen("../passwd")will fail. This is a security feature, not a bug. It sandboxes the FS. Also, the behavior of symlinks is implementation-defined. Inos.DirFS, they are followed, but you can’t rely on that for a customFS. - No absolute paths. All paths are relative to the root of the
fs.FSinstance. The root directory is.. This is whyos.DirFS("/etc")lets youOpen("passwd")but notOpen("/passwd").
Beyond the Disk: Embedding and Other Magic
The real power unlocks when you stop using it for the disk it’s already abstracting. The embed package in Go 1.16+ is its perfect partner. You //go:embed your static files into your binary, and it gives you an fs.FS to access them.
//go:embed website/static/*
var staticContent embed.FS
// Later, in your HTTP server setup
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
Notice the http.FS wrapper? That’s a tiny adapter that takes an fs.FS and makes it work with the http.FileServer expectation of a http.FileSystem. It’s adapters all the way down, and it’s beautiful.
You can find third-party libraries that provide an fs.FS interface for S3 buckets, ZIP files, or Git repositories. Your code that processes files doesn’t need to change; it just takes the fs.FS it’s given and does its job. This is the kind of decoupling that makes software maintainable and a genuine pleasure to work with. It’s not just a fancy way to open files; it’s a fundamental redesign of how we think about data access.