1. Basic io/fs interfaces
When you are just learning to program, it feels natural: “If I need a file, I just read the file.” It is like a kettle: want tea — pour water — turn it on. But when a program becomes a bit bigger than “Hello, world”, it suddenly turns out that “just read a file” drags in extra questions: where the file is located, what permissions the process has, what the current directory is, how to test it, instead of “works on my laptop.”
And this is where adult life begins: a file system is a dependency, roughly like a database or a network. And dependencies are useful to swap out, constrain, test, and control. The io/fs package provides a standard “language” (interfaces) to describe file access without tying yourself to os.*.
Imagine your learning CLI project (let it be our small task tracker tasker) stores files in the data/ directory. Previously, anywhere we could write os.ReadFile("data/tasks.txt"). It is convenient… until you need to write a test that runs on CI, where your data/ directory does not exist, and where there “just happens” to be no permission to read. But if your logic accepts fs.FS, the test feeds it “in-memory files,” and everybody is happy.
fs.FS: the minimal “file tree” contract
If you have ever looked at the Go standard library and thought, “Why are there so many interfaces?”, congratulations: you are at the doorstep of understanding “why Go is Go.” In io/fs, everything starts with a very small contract.
The fs.FS interface is literally “being able to open a file by name”:
type FS interface {
Open(name string) (File, error)
}
At this point it is useful to slow down. One method. This is not “a file system in general,” not “the OS,” not “the disk.” It is simply: “give me the file called name, inside your world.”
And here is the key term of the day: an FS path (a path inside fs.FS). This is not an “OS path.” It is a relative name inside a particular file system.
Below is a minimal, but already useful function for our tasker: read a text file from the provided FS (with zero os.* inside):
package files
import (
"io/fs"
)
func ReadText(fsys fs.FS, name string) (string, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", err
}
return string(b), nil
}
Here we used fs.ReadFile (a helper from io/fs). It will call Open, read the whole file, and close it. This is handy when the file is small (config, template, help text). If the file is huge, reading it all at once is not the best idea, but today our goal is to understand the contract, not to compete in optimization.
fs.File: a file as a resource, and why Close() matters
When fs.FS.Open succeeds, you get an fs.File. And here Go again pretends that “everything is simple,” but actually gives you the right model of the world: a file is a resource, and it must be closed.
A fs.File has three basic capabilities: read (Read), close (Close), and get metadata (Stat). This does not have to be “a real file on disk”: it can be an in-memory file, a file inside an archive, a file inside an embed resource — it does not matter. You still need to close it, because the implementation may hold file descriptors, locks, internal buffers, etc.
Let us write a small function for our tasker: get the size of the tasks file. Here we intentionally do not use fs.Stat (the helper), but show the “manual” path via Open → defer Close → Stat:
package files
import (
"io/fs"
)
func FileSize(fsys fs.FS, name string) (int64, error) {
f, err := fsys.Open(name)
if err != nil {
return 0, err
}
defer f.Close() // always close
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
Notice the psychological point: defer f.Close() — this is not “magic,” it is a way not to forget to close the resource, especially if below you will have five more return statements in different branches. If it feels like “closing is not necessary, it is small,” that is like “no need to buckle up, I am just going to the store.” Usually it is exactly “to the store” when the thing that takes the whole evening to fix happens.
fs.ReadFileFS: a narrow interface — “it is enough for me to be able to ReadFile”
Sometimes you know ahead of time that you do not need streaming access via Open, and you definitely want only ReadFile(name) (i.e., “read the whole thing”). In io/fs, for this there is a narrow interface fs.ReadFileFS.
Why does this matter? Because narrow interfaces are one of the core habits of a Go developer. Instead of “give me a huge dependency for everything,” we say: “I need this specific capability.” Then the code is easier to test and harder to misuse.
Here is an example: our tasker wants to read the command help text from the file help/main.txt. We can declare a function that accepts exactly fs.ReadFileFS:
package helptext
import (
"fmt"
"io/fs"
)
func LoadHelp(rfs fs.ReadFileFS, name string) (string, error) {
b, err := rfs.ReadFile(name)
if err != nil {
return "", fmt.Errorf("load help %q: %w", name, err)
}
return string(b), nil
}
We added wrapping via "%w" to preserve the root cause of the error (this will be useful when you want to distinguish “file not found” from “access denied” or “FS implementation is broken”). The approach “errors as values” and the ideas around wrapping/cause checks are a direct continuation of what we have already practiced earlier.
Now an important question: what if we only have an fs.FS, but we want to optimally use ReadFile where it is supported? Then you can apply the safe check you already know via type assertion (v, ok := x.(T)). And yes, this is exactly the case where type assertion is not a “trick,” but a normal tool: “if you can do more — I will use it; if not — I will work by the base contract.”
package helptext
import (
"io/fs"
)
func LoadHelpSmart(fsys fs.FS, name string) (string, error) {
if rfs, ok := fsys.(fs.ReadFileFS); ok {
b, err := rfs.ReadFile(name)
if err != nil {
return "", err
}
return string(b), nil
}
// fallback: general path via fs.ReadFile
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", err
}
return string(b), nil
}
If you suddenly forgot what type assertion is, here is the meaning in plain language: “try to view the interface-typed value as a more specific interface/type; if it works — great; if not — just do not panic.” And this is directly related to the fact that in Go interfaces are contracts, not a “class hierarchy.”
3. A framing about paths: filepath and path
Now comes a moment where a huge number of beginners get tripped up — and that is normal. We have been living in the world of OS paths: C:\Projects\... on Windows, /home/user/... on Linux/macOS. There are separators, drives, absolute paths, relative paths, current working directory, and all of that is handled by the path/filepath package.
But in the world of io/fs a path is not an OS path, it is a name inside an FS. And that name has important properties:
- Usually the separator is /, regardless of the OS.
- Usually the path is relative (without an “absolute meaning”).
- Security and validity rules for paths are a separate topic, but even now it is useful not to mix “name inside FS” and “OS path.”
To avoid confusion, here is a simple cheat sheet:
| What we are describing | Which “paths” package | Which separator to expect |
|---|---|---|
| Path in the operating system (drive/directories) | |
depends on OS (\ or /) |
| Path/name inside fs.FS (virtual tree) | |
always / |
Let us look at the difference in code. Here is building an OS path (when you are really on disk and want to “join a folder and a file name”):
package main
import (
"fmt"
"path/filepath"
)
func main() {
p := filepath.Join("data", "tasks.txt")
fmt.Println(p) // on Windows: "data\\tasks.txt", on Linux/macOS: "data/tasks.txt"
}
And here is building an FS path (a file name inside fs.FS, where the “path language” is usually /):
package main
import (
"fmt"
"path"
)
func main() {
name := path.Join("help", "main.txt")
fmt.Println(name) // help/main.txt
}
Why do we care about this distinction at all? Because if you start feeding into fs.FS.Open a path assembled via filepath.Join on Windows, you will get a name with \, while inside a virtual FS (and many implementations) / is expected. And then your program will “mysteriously work on one computer and mysteriously not work on another.” This is a classic bug that looks like a curse, but in reality it is just different path conventions.
To reinforce it, here is a small diagram. It shows that we effectively have two “layers of paths,” and you cannot mix them in one pot:
flowchart TD
A["Your code"] --> B["OS path: filepath.* (absolute/relative, OS-dependent)"]
A --> C["FS path: path.* (inside fs.FS, separator always /)"]
B --> D["os.Open/os.ReadFile (working directly with the disk)"]
C --> E["fsys.Open / fs.ReadFile (working through fs.FS)"]
In short and honestly: filepath is “as on disk,” path is “as in a virtual tree.”
4. Mini refactor of tasker: reading files via fs.FS
Now we will do a very typical step that in real projects often looks like: “oh, why did we not do it this way right away?” We will take reading some texts from files and remove direct os.ReadFile calls from the logic.
Suppose tasker has a file with a hint about the task format docs/format.txt, and also a greeting file docs/banner.txt. Previously we could write:
// was (tied to disk):
// b, err := os.ReadFile("docs/banner.txt")
Now we do it the grown-up way: the uihelp package reads files via fs.FS, and it does not care where they come from.
package uihelp
import (
"fmt"
"io/fs"
)
func Banner(fsys fs.FS) (string, error) {
b, err := fs.ReadFile(fsys, "docs/banner.txt")
if err != nil {
return "", fmt.Errorf("read banner: %w", err)
}
return string(b), nil
}
An important detail: inside Banner there is not a word about the current directory, absolute paths, drives, and so on. There is only the contract: “the FS must contain the file docs/banner.txt.” This drastically simplifies life, because:
- Logic is easier to test (we will inject an FS with the needed files).
- Logic is easier to reuse (it does not matter where the files come from).
- Errors are easier to diagnose (we added context “read banner”).
If you want to ask right now, “OK, but how do I create fsys on disk?”, that is a solid question — and the right time for it is after we confidently hold in our head the fs.FS contract and the difference between paths. Today we lock in: the logic accepts fs.FS, and “how exactly fsys is set up” is an implementation detail at the application boundary.
5. Typical mistakes when working with fs.FS and paths
Mistake #1: confusing an FS path with an OS path because “they are all strings anyway.”
Strings are indeed strings everywhere, but the meaning is different. If you are building a name for fsys.Open, follow the io/fs convention: usually / and relative names. For OS paths, use filepath. As soon as you start mixing filepath.Join and fsys.Open, bugs become “cross-platform,” i.e., the most unpleasant kind.
Mistake #2: forgetting to close fs.File after Open.
In examples with fs.ReadFile, closing happens “under the hood,” and that relaxes you. But as soon as you do fsys.Open, immediately add defer f.Close(). This is not bureaucracy: different implementations of fs.FS may hold real resources, and you do not want to learn what a “file descriptor leak” is the hard way.
Mistake #3: accepting an API contract that is too broad, and then using only a small part.
If your function only needs ReadFile(name), accept fs.ReadFileFS. It makes expectations clearer. If you only need Open, accept fs.FS. In Go, good style is to start with the minimally sufficient interface.
Mistake #4: wrapping an error but losing the cause.
If you write fmt.Errorf("...: %v", err), the cause is lost for errors.Is. If you write fmt.Errorf("...: %w", err), the cause is preserved, and higher-level code can correctly recognize the type/class of the error. This is part of the core philosophy “errors as values” and one of the reasons why Go loves errors.Is/errors.As.
Mistake #5: expecting fs.ReadFile to fit “everything.”
fs.ReadFile reads the entire file. For small texts that is great, but for large files it may be inappropriate. Even if today we read a “banner” and “help,” keep in mind: reading everything at once is a conscious choice, not a default for all cases.
GO TO FULL VERSION