I once shipped a Go binary to a client’s server and watched it fall over on the very first request. The templates were missing. I had built the program on my laptop, where a folder called templates sat right next to the binary, and I had forgotten to copy that folder across. The program compiled fine. It ran fine. It just could not find anything to render.
That afternoon taught me more about Go’s file abstractions than any documentation page ever has. The fix turned out to be tiny. Instead of reading templates from a hard-coded directory, I baked them into the binary itself. Once I did that, I realised the same trick works for config files, static websites, SQL migrations, test fixtures, and just about anything else that lives in a directory.
The tool that makes this possible is a small interface with exactly one required method.
type FS interface {
Open(name string) (File, error)
}
That is the whole contract. One method. Everything else in the io/fs package — ReadFile, ReadDir, Stat, WalkDir, Sub, Glob — is a plain function that takes an FS value and works through that single method. Once that clicked for me, the package stopped looking like magic and started looking like a thin, friendly layer over os.
Here are ten patterns I reach for again and again. Each one fits in a short block of code, and each one teaches something about how the standard library was built to be extended.
The first pattern is compiling files into the binary with //go:embed. The directive must sit immediately above a variable declaration, and the variable has to be a string, a []byte, or an embed.FS. Anything else is a compile error.
package web
import "embed"
//go:embed assets
var embedded embed.FS
That single comment pulls the entire assets directory tree into the binary at build time. The tree shows up as a read-only filesystem rooted at the word assets. So a file on disk at web/assets/css/app.css becomes reachable inside the program as assets/css/app.css.
The pattern rules are strict and worth memorising. Patterns use path.Match syntax, so * does not cross directory boundaries the way you might hope. You cannot use ... You cannot start a pattern with /. And files whose names begin with a dot or an underscore are skipped unless you prefix the pattern with all:.
//go:embed all:assets
var embedded embed.FS
I learned that the hard way when a .well-known directory vanished from a build and broke a certificate check in production. If your assets include hidden files, all: is not optional.
The directory layout matters more than people expect. I always keep embedded files inside a named subdirectory rather than scattering them at the package root, because then the root path in the embedded filesystem is predictable. It is always the directory name, never ., never something machine-dependent.
The second pattern is fs.Sub, and it exists to keep callers inside their lane. When you embed a whole assets tree but a particular handler only needs the js folder, you hand it a narrowed view.
func scriptFS() (fs.FS, error) {
return fs.Sub(embedded, "assets/js")
}
Now the caller sees bundle.js, not assets/js/bundle.js. It cannot open assets/img/logo.png because that path simply does not exist inside the sub-filesystem. fs.Sub returns an error only if the path is invalid, not if the directory is missing, so a typo here fails at Open time rather than at startup. I usually call fs.Stat right after to catch that early.
The value of this pattern is not just tidiness. It is a permission boundary. A plugin author writing a renderer cannot reach outside the slice you gave them, no matter how clever the path they construct, because fs.Sub rejects anything that escapes.
The third pattern is the development-versus-production switch. During development I want to edit a CSS file and refresh the browser. In production I want a frozen snapshot locked inside the binary. Both can satisfy the same interface.
func assetFS() (fs.FS, error) {
if dir := os.Getenv("ASSETS_DIR"); dir != "" {
return os.DirFS(dir), nil
}
return fs.Sub(embedded, "assets")
}
os.DirFS takes a directory and returns an fs.FS rooted there. It accepts forward slashes on every platform, converts them internally on Windows, and refuses absolute paths and any path containing ... That last part matters because it means the same path string works against either source without special handling.
I set ASSETS_DIR in my shell profile for local work and never set it on servers. The binary then falls back to the embedded copy. One branch, one environment variable, and no build tags to maintain.
The fourth pattern is testing with fstest.MapFS. This one changed how I write tests. Instead of creating temporary directories and cleaning them up afterwards, I describe a filesystem as a literal.
func TestLoadConfig(t *testing.T) {
fsys := fstest.MapFS{
"config/app.yaml": &fstest.MapFile{
Data: []byte("name: demo\nport: 8080\n"),
},
"config/deep/nested/notes.txt": &fstest.MapFile{
Data: []byte("nothing to see"),
},
}
cfg, err := LoadConfig(fsys, "config/app.yaml")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != 8080 {
t.Fatalf("port = %d, want 8080", cfg.Port)
}
}
Directories do not need to be declared. MapFS works them out from the file paths. Each MapFile can set Data, Mode, ModTime, and Sys, which means I can fabricate a read-only file or a file dated in 1970 without touching a disk.
The pattern becomes really useful when I want to test ugly cases. I can build a tree with two hundred nested directories, or a file named CON that would be illegal on Windows, or an empty file, or a file with a null byte in the middle. None of it touches the real machine, and the test runs in microseconds.
I also use MapFS as a mutable fixture. Because it is a map underneath, I can add entries between test cases and watch how my code reacts to a filesystem that changes shape mid-run.
The fifth pattern is wrapping an fs.FS to add behaviour. This is where Go’s interface design pays off. Since there is only one method to implement, a wrapper is about fifteen lines.
type loggingFS struct {
inner fs.FS
log *log.Logger
}
func (l loggingFS) Open(name string) (fs.File, error) {
f, err := l.inner.Open(name)
l.log.Printf("open %q err=%v", name, err)
return f, err
}
I drop this into a staging environment whenever I need to see which files a service actually reads during startup. It is remarkable how often the answer differs from what the documentation claims.
A more useful wrapper enforces a size ceiling, which protects against a huge file accidentally landing in an upload directory.
type limitedFS struct {
inner fs.FS
max int64
}
func (l limitedFS) Open(name string) (fs.File, error) {
f, err := l.inner.Open(name)
if err != nil {
return nil, err
}
return &limitedFile{File: f, remaining: l.max}, nil
}
type limitedFile struct {
fs.File
remaining int64
}
func (l *limitedFile) Read(p []byte) (int, error) {
if l.remaining <= 0 {
return 0, io.EOF
}
if int64(len(p)) > l.remaining {
p = p[:l.remaining]
}
n, err := l.File.Read(p)
l.remaining -= int64(n)
return n, err
}
There is a trade-off here worth naming. By embedding fs.File, the wrapper loses any optional interfaces the inner file implemented. If the original file satisfied fs.ReadDirFile, the wrapper no longer does, and a directory listing through it will fail. When I need those interfaces preserved, I write explicit forwarding methods for ReadDir, Stat, and Close instead of embedding. It is more typing, but it keeps the wrapper honest.
The sixth pattern is serving embedded assets over HTTP. The standard library already consumes fs.FS in net/http, so this is mostly plumbing.
func main() {
assets, err := fs.Sub(embedded, "assets")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/static/",
http.StripPrefix("/static/",
http.FileServer(http.FS(assets))))
log.Fatal(http.ListenAndServe(":8080", mux))
}
The http.FS adapter converts an fs.FS into an http.FileSystem, which is the older interface http.FileServer expects. The adapter also rejects paths containing .., so directory traversal attempts bounce off before they reach your code.
One thing that bit me: http.FileServer will happily generate directory listings. If your embedded tree has a directory without an index.html, visitors can browse it. That is usually not what you want for a production site.
func noListing(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
})
}
Wrapping the handler this way blocks directory pages while leaving normal file requests alone. I also add a cache header, because embedded files never change between deploys.
func withCache(h http.Handler, maxAge time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control",
fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds())))
h.ServeHTTP(w, r)
})
}
The seventh pattern is walking a filesystem with fs.WalkDir. Most people start with filepath.Walk because they have used it before. The newer function is better, and the reason is subtle.
filepath.Walk hands your callback an os.FileInfo, which means it has to call Stat on every single entry it visits. On a slow disk or a network mount, that is one extra round trip per file. fs.WalkDir hands you an fs.DirEntry instead. That entry already knows its name and its type bits, so most callbacks never need a Stat at all.
var total int64
err := fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".css" && filepath.Ext(path) != ".js" {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
Two special sentinel errors control the walk. Returning fs.SkipDir from a directory callback prunes that entire branch. Returning fs.SkipAll stops the walk immediately with a nil error, which is handy when you find what you were looking for and do not care about the rest.
I use this to build a manifest at startup: walk the embedded tree once, hash every file, and store the result in a map. Then a middleware can compare request paths against known hashes and skip work for anything that does not exist.
The eighth pattern is reading errors correctly, and it is where I see the most bugs in code written by people who are new to io/fs.
There is a real difference between a file that is not there, a file that is there but unreadable, and a path that is simply malformed. Collapsing all three into a single err != nil check makes debugging painful.
func exists(fsys fs.FS, name string) (bool, error) {
_, err := fs.Stat(fsys, name)
if err == nil {
return true, nil
}
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}
fs.ErrNotExist covers the missing-file case. fs.ErrPermission covers the unreadable case. fs.ErrInvalid covers paths that are malformed, absolute, or contain ... And fs.ErrExist shows up when you try to create something that is already there, which does not apply to read-only embedded files but does apply to os.DirFS.
The functions in io/fs wrap these sentinels in a *fs.PathError that carries the operation and the path. So the error message already tells you whether you failed at open, stat, or read. You just have to look.
if _, err := fs.ReadFile(fsys, "config/app.yaml"); err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Printf("op=%s path=%s underlying=%v",
pathErr.Op, pathErr.Path, pathErr.Err)
}
return err
}
There is also a helper called fs.ValidPath that tells you whether a string is even a legal path in an fs.FS. I use it at API boundaries, before the string travels any deeper, so that a bad path fails immediately with a clear message instead of somewhere five function calls later.
The ninth pattern is layered lookup. This comes up constantly in configuration: ship a sensible default, let the user override it. Precedence is easy to express when both sources are fs.FS values.
type overlay struct {
layers []fs.FS
}
func (o overlay) Open(name string) (fs.File, error) {
for _, layer := range o.layers {
f, err := layer.Open(name)
if err == nil {
return f, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
}
return nil, &fs.PathError{
Op: "open",
Path: name,
Err: fs.ErrNotExist,
}
}
Pass the user directory first and the embedded defaults second. If the user override returns a genuine read error, the loop stops and surfaces it rather than silently falling through to the safe default. That distinction matters. Someone whose config file is corrupted needs to hear about it, not get quietly served defaults.
File lookups merge cleanly this way. Directory listings do not, because two layers may each have entries under the same directory name and you have to decide how to combine them. When I need that, I write an explicit ReadDir that gathers names from every layer, deduplicates, sorts, and returns a merged slice.
func (o overlay) ReadDir(name string) ([]fs.DirEntry, error) {
seen := make(map[string]fs.DirEntry)
for _, layer := range o.layers {
entries, err := fs.ReadDir(layer, name)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return nil, err
}
for _, e := range entries {
if _, found := seen[e.Name()]; !found {
seen[e.Name()] = e
}
}
}
if len(seen) == 0 {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
}
out := make([]fs.DirEntry, 0, len(seen))
for _, e := range seen {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out, nil
}
Adding ReadDir means the type now satisfies fs.ReadDirFS, which lets the standard helpers skip the slower fallback path where they open the directory and call ReadDir on the file handle.
The tenth pattern is the meta one, and it is the habit I wish I had formed years earlier: never accept a file path as a string. Accept an fs.FS and a name.
func LoadConfig(fsys fs.FS, name string) (*Config, error) {
data, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", name, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", name, err)
}
return &cfg, nil
}
Look at what that signature gives me. In production I pass the embedded filesystem. In development I pass os.DirFS("."). In tests I pass a fstest.MapFS. In a future version where configs come from a zip archive or an S3 bucket, I implement Open on a small struct and pass that. The function body never changes.
I have watched this decision save entire refactors. A service that started with an embedded config grew a customer-specific override directory, then a remote source, and the loader never changed once. All three sources implemented Open.
If you take nothing else from this, take that. The io/fs package is not really about embedding files. It is about making the source of a file irrelevant to the code that reads it. If you never use //go:embed at all, you still benefit, because every helper in the package speaks the same language.
I keep a small checklist in my head when I write filesystem code. Does the function take an fs.FS or a string? Am I distinguishing “not found” from “unreadable”? Did I use fs.Sub to narrow what a caller can reach? Am I calling Stat inside a walk loop when DirEntry would do?
Most of my early bugs in this area came from violating one of those four. The bugs since have been few and boring, which is exactly what I want from code that touches the disk.