1. How os.DirFS works
If you already know how to do os.Open("data/config.txt"), the natural question is: why do we need yet another “virtual file system”. The reason is simple: the path to a file and the way to reach the file are two different tasks. os.Open hard-binds us to the disk and OS paths, while os.DirFS lets you say: “here’s your root, stay inside it”, and then work the same way on disk, in tests, and (for example) with embedded resources.
Imagine your app reads files from the data/ folder: templates, help, examples, a “default config”. While you write code alone on your own machine, everything is great. But as soon as you introduce testing, CI, different working directories, different OSes (Windows likes \, Unix likes /), the classic tragicomedy begins: “it works for me, not for you”.
os.DirFS is like “letting the app go for a walk”, but inside a strictly fenced yard: you give it a root directory, and it walks only inside it, using FS paths. And yes, it also greatly simplifies tests: you can write a function so it accepts an fs.FS, and in a test substitute a “fake” FS.
What does os.DirFS(dir) return?
When you first see os.DirFS("data"), your brain may try to interpret it as “well, it’s just a path string, but with extra features”. But in reality this is an important architectural step: os.DirFS returns a value that implements the fs.FS interface. That is, you get an object that has an Open(name string) method — and this Open opens files relative to the root dir.
It’s important to keep this thought: os.DirFS does not “read a file”. It does something else: it turns a “piece of disk” into a “virtual FS”, where the root is dir, and all operations use internal names.
It’s handy to picture it schematically like this:
flowchart TD
A["Disk (OS)"] --> B["directory dir = data/"]
B --> C["os.DirFS(dir) => fs.FS"]
C --> D["fs.ReadFile(fsys, 'cfg/app.txt')"]
D --> E["Opens data/cfg/app.txt on disk"]
So fsys := os.DirFS("data") gives you a “window” into the data/ subtree, and then in code you say not data/cfg/app.txt, but just cfg/app.txt.
FS paths are not OS paths
The most common confusion here is not about code, but about the mental model. We already know how to build OS paths via filepath.Join, clean them via filepath.Clean, check whether a path is absolute, and so on. But io/fs introduces a different convention: an FS path is a name inside an fs.FS file system. It does not have to match an OS path, and most often it does not.
An FS path almost always follows simple rules: it’s relative, it uses / as the separator, and it does not start with /. For OS paths, the rules are different and depend on the platform.
It’s useful to keep a “cheat sheet” in sight:
| What you build | Where it’s used | What to build it with | Separator |
|---|---|---|---|
| OS path (real path on disk) | os.Open, os.ReadFile, os.Stat | |
depends on the OS (/ or \) |
| Path inside fs.FS | fs.ReadFile, fs.Stat, fsys.Open | |
always / |
In short, filepath is “I’m talking to the operating system”, and path is “I’m working with URL/FS-style paths (via /)”.
A mini example to feel the difference:
package main
import (
"fmt"
"path"
"path/filepath"
)
func main() {
fmt.Println(filepath.Join("cfg", "app.txt")) // cfg/app.txt (on Unix) or cfg\app.txt (on Windows)
fmt.Println(path.Join("cfg", "app.txt")) // cfg/app.txt
}
In the world of os.DirFS and io/fs, what we care about is path.Join, because an FS path always uses /, even if you run the program on Windows.
Reading a file from a virtual root
Let’s do the most basic thing: read the file data/cfg/app.txt, but in a way where data/ is the root, and the code works with a relative name. Notice right away: we do not use os.ReadFile("data/..."), we use fs.ReadFile(fsys, "..."), because fsys is an fs.FS.
package main
import (
"fmt"
"io/fs"
"os"
)
func main() {
fsys := os.DirFS("data")
b, err := fs.ReadFile(fsys, "cfg/app.txt")
if err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Println(string(b)) // for example: My App
}
A quiet but important kind of magic happens here, without special effects: fs.ReadFile works on top of fs.FS, and the specific implementation of fs.FS is what os.DirFS provides. As a result, the reading code does not know whether this is on disk or not—it just works with the abstraction.
And that “does not know” is not a drawback, it’s a superpower: such code is much easier to test and port.
Why you can’t pass a “full path” into fs.FS
Beginners often do this: “if the file on disk is data/cfg/app.txt, then I’ll pass data/cfg/app.txt into fs.ReadFile”. But that breaks the very idea of DirFS.
After you do fsys := os.DirFS("data"), the data root is already accounted for. Passing it again is like wearing a hat twice: it won’t double the warmth, but you will start looking suspicious.
Here’s an example of “how not to do it”:
package main
import (
"fmt"
"io/fs"
"os"
)
func main() {
fsys := os.DirFS("data")
_, err := fs.ReadFile(fsys, "data/cfg/app.txt")
fmt.Println(err) // usually: open data/cfg/app.txt: no such file or directory
}
Why is that? Because inside this FS, the name "cfg/app.txt" corresponds to the real file "data/cfg/app.txt". And "data/cfg/app.txt" inside this FS means “data/data/cfg/app.txt”.
This, by the way, is one reason why DirFS enforces discipline: you stop scattering “full paths” across the code and start thinking in terms of “roots” and “relative names”.
“File not found” errors: how to detect them correctly
When we work through io/fs, we want to check “not found” in a uniform way. In this world, the canonical cause is fs.ErrNotExist, and the check is done via errors.Is. The idea of errors.Is and “chains of causes” is part of modern Go error culture: an error can be wrapped, but the underlying cause must be detectable.
Let’s show the correct pattern:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
fsys := os.DirFS("data")
_, err := fs.Stat(fsys, "nope.txt")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("file is missing")
return
}
if err != nil {
fmt.Println("other error:", err)
return
}
fmt.Println("exists")
}
There are two important points here.
First: we don’t compare errors by their string, because a string is “how it was written”, not “what happened”.
Second: we’re not afraid that the error was wrapped higher up. As long as everyone wraps correctly (via %w), errors.Is will keep working. This follows directly from the “errors are values” approach: errors should be usable for logic, not just for printing.
3. Embedding os.DirFS into an application
Now let’s write a piece of code that already looks like a “real application”, not just main. Suppose our educational project has a help text file data/help/main.txt. We want to make a function that reads that help via fs.FS, and let main decide where to get the FS from (disk, test, or somewhere else).
resources package: reading text by name
Let’s make a small package that can read text files:
package resources
import (
"fmt"
"io/fs"
)
func ReadText(fsys fs.FS, name string) (string, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", fmt.Errorf("read %q: %w", name, err)
}
return string(b), nil
}
Notice %w: we added context (“what we were reading”), but preserved the cause, so that errors.Is can recognize, for example, fs.ErrNotExist. This is what “correct” wrapping looks like.
main: create an FS via os.DirFS and read the help
Now main becomes very neat:
package main
import (
"fmt"
"os"
"example.com/app/resources"
)
func main() {
fsys := os.DirFS("data")
s, err := resources.ReadText(fsys, "help/main.txt")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(s)
}
And here a nice effect appears: if tomorrow you decide that “resources” should live not on disk, but in memory, you won’t have to change resources.ReadText. You’ll change only the dependency “wiring”, i.e., what gets passed as an fs.FS.
Building paths inside an FS: carefully, via path.Join
Sooner or later you’ll want to read not one fixed file, but different ones, for example help/<topic>.txt. At that point many people slide into string concatenation like "help/" + topic + ".txt"—and yes, it “works”… until it stops “working”.
It’s better to build the FS path explicitly via path.Join, because that guarantees / and proper handling of slashes:
package resources
import (
"path"
)
func HelpPath(topic string) string {
return path.Join("help", topic+".txt")
}
And use it like this:
package main
import (
"fmt"
"os"
"example.com/app/resources"
)
func main() {
fsys := os.DirFS("data")
name := resources.HelpPath("main")
fmt.Println(name) // help/main.txt
_ = fsys
}
Yes, the example is a bit “toy-like”, but it trains the right habit: build an FS path as an FS path, not as an OS path.
4. DirFS limitations and security questions
It’s very easy to start thinking that os.DirFS("data") is a rock-solid sandbox: like, “that’s it, we’re safe, nobody can get out”. In practice, DirFS does provide an important restriction: you won’t be able to open a name with .. or a leading / as a normal FS path. So it blocks “trivial path traversal via ../..”.
But it’s important to understand the boundary of responsibility: DirFS is primarily a convenient implementation of fs.FS for a subtree on disk, not a universal security system. For example, if there are symlinks inside the root, they may point anywhere—and that’s a separate topic (today we won’t dive deep into it; we’ll just note that path security is not a single checkbox in code, but a set of measures).
One more practical nuance: if you write os.DirFS("data"), then data is relative to the process’s current working directory. In an IDE, in tests, and in CI, the current directory may differ, so it’s important either to strictly control where you run the program, or to pass an absolute root path (but that’s already an OS path, and it’s usually built via filepath.Abs/filepath.Join at the application boundary).
5. Common mistakes when working with os.DirFS
Mistake #1: mixing FS paths and OS paths in the same place.
It usually looks like this: you created fsys := os.DirFS("data"), and then you suddenly do fs.ReadFile(fsys, filepath.Join("cfg", "app.txt")). On Unix it may “accidentally” work, but on Windows you’ll get a path with \, which is not a valid separator for FS paths. The fix is simple discipline: inside fs.FS use / and, when needed, path.Join, and keep filepath for talking to the OS.
Mistake #2: passing a name with a leading / into fs.ReadFile.
An FS path is relative. The string "/cfg/app.txt" for fs.FS is not an “absolute path”, it’s just a “bad name”. This is a common mistake after long days working with os.Open. A helpful habit: first set the root via DirFS, then use names without a leading /.
Mistake #3: accounting for the root twice (data/... inside an FS that is already DirFS("data")).
This leads to attempting to open data/data/... and a predictable “not found”. The simplest protection is to keep an invariant comment near the code: “inside this FS, all names are relative to data/”.
Mistake #4: wrapping errors without %w and then being surprised that errors.Is doesn’t work.
If you write fmt.Errorf("read %q: %v", name, err), you turn the error cause into text. Then errors.Is(err, fs.ErrNotExist) will stop working, and you’ll start building string comparisons (which is already close to “bug archaeology”). The correct variant is fmt.Errorf("read %q: %w", name, err).
Mistake #5: expecting os.DirFS to solve all security questions.
DirFS is useful and does block some bad names, but path security is not only about forbidding ... Even in educational projects it’s important to keep in mind: external input (what came from the user) must not be blindly substituted into file operations. You need to validate names and design the API so that “dangerous strings” cannot reach the file layer.
GO TO FULL VERSION