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.

  1. 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.
  2. No .. or symlinks. The Open method does not allow a name containing .. to escape the root of the tree. If you have os.DirFS("/etc"), trying to Open("../passwd") will fail. This is a security feature, not a bug. It sandboxes the FS. Also, the behavior of symlinks is implementation-defined. In os.DirFS, they are followed, but you can’t rely on that for a custom FS.
  3. No absolute paths. All paths are relative to the root of the fs.FS instance. The root directory is .. This is why os.DirFS("/etc") lets you Open("passwd") but not Open("/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.