1. os.Stat and os.FileInfo
Before reading the contents (and sometimes instead of reading), it is convenient to fetch metadata: name, type (file/directory), size, modification time, “mode” (permissions and type). Metadata helps you make decisions in code: whether you can read a file, whether it is worth parsing, whether you mixed up a path to a folder and a file, and why the program “suddenly” stopped working on someone else’s computer.
Key idea: metadata ≠ contents. Metadata is the file system object’s “passport”, and contents are the data inside the file.
os.Stat: a basic query to the file system
os.Stat is a standard library function that takes a path (a string) and returns metadata for the object at that path. It is one of the most practical functions, because almost any program that works with files eventually has to answer: “does the path exist?” and “is this definitely a file?”.
The signature looks like this:
info, err := os.Stat(path)
If everything is OK, err == nil, and info contains metadata. If something went wrong (no such path, no permissions, a “broken” path), then err != nil, and you must not use info — this is an important habit.
A minimal example of “find out whether the path exists and what it is”:
package main
import (
"fmt"
"os"
)
func main() {
info, err := os.Stat("data/tasks.json")
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Println("isDir:", info.IsDir()) // isDir: false (if this is a file)
}
Important: os.Stat does not read the whole file and does not parse it. It fetches information about the object itself.
The os.FileInfo interface and how to read it
os.Stat returns not a struct, but the os.FileInfo interface. This is done on purpose: different file system implementations (and even some helper packages) may store metadata differently, but what is exposed is a single contract.
Useful FileInfo methods are our “passport glossary”:
| FileInfo method | What it means | Practical meaning |
|---|---|---|
|
base object name (without the path) | print nicely, log |
|
whether it is a directory | the main “folder/file” branch |
|
size in bytes | answer “empty file?” / estimate volume |
|
last modification time | compare “newer/older”, show to the user |
|
mode (type + permissions) | diagnostics, check “regular file?” |
Immediately remember one “expectations trap”: Size() for a directory does not mean “the total size of all files inside”. It is just a technical metadata field for the directory, which has different meanings across operating systems. To get the “folder size”, you need to walk its contents, but that is a different task.
3. Practice: the inspect utility and ModTime()
The inspect utility: print metadata by path
When you are learning a new function, it helps to build a tiny “magnifying glass” utility: “enter a path → see what the system knows about it”. It quickly clarifies what metadata is and helps with debugging when a project starts behaving “not as expected”.
Let’s make a program that reads a path from stdin and prints the “passport”:
package main
import (
"fmt"
"os"
)
func main() {
var path string
fmt.Print("Enter path: ")
fmt.Scan(&path)
info, err := os.Stat(path)
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Printf("name=%s isDir=%v size=%d\n", info.Name(), info.IsDir(), info.Size())
}
If you enter a path to a file, the output will look roughly like this:
Enter path: data/tasks.json
name=tasks.json isDir=false size=128
At this point, many people notice an important thing for the first time: Name() is not the full path, but only the name of the last element.
Modification time: ModTime() and formatting
ModTime() is useful even in small projects: you can show the user “the file was updated at ...”, and in debugging you can see that the file has not changed at all (even though you were sure it did).
Since ModTime() returns time.Time, we use time formatting. For example, RFC3339 is a convenient machine-readable format:
package main
import (
"fmt"
"os"
"time"
)
func main() {
info, err := os.Stat("data/tasks.json")
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Println(info.ModTime().Format(time.RFC3339)) // 2026-01-16T12:34:56-05:00
}
If you see a time that looks “weird”, it is a good reason to remember: different machines can be in different time zones. This is not a Go bug — it is the reality of time.
4. Checking the task storage file in TaskBox
In a learning app (let’s call it TaskBox), we store data on disk, for example in "data/tasks.json". Typical beginner problems: the program tries to read a file, but there is a folder at that path; the file is missing; there are no permissions; the path is wrong (especially when the project is started from a different working directory).
Instead of “crashing” somewhere inside ReadFile, let’s do a “passport check” via os.Stat.
First, let’s create a function that returns the storage path:
package main
import "path/filepath"
func storagePath() string {
return filepath.Join("data", "tasks.json")
}
Now let’s validate: does the path exist, and is it a file (not a directory).
package main
import (
"fmt"
"os"
)
func mustBeFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %q: %w", path, err)
}
if info.IsDir() {
return fmt.Errorf("%q is a directory, want a file", path)
}
return nil
}
And use it in main before reading:
package main
import (
"fmt"
)
func main() {
path := storagePath()
if err := mustBeFile(path); err != nil {
fmt.Println("storage check failed:", err)
return
}
fmt.Println("storage looks OK")
}
Notice: we do not read the contents, but we already protect ourselves from half of the “weird situations” that would otherwise turn into non-obvious errors and questions like “why did it work yesterday?”.
5. Handling os.Stat errors
File system errors are not an exception; they are a normal execution branch. And os.Stat is especially honest here: if it cannot fetch metadata, it says so via err. It is important to learn to distinguish at least two common classes: “does not exist” and “no permission”.
For this, the os package has handy predicate functions: os.IsNotExist(err) and os.IsPermission(err).
Here is an example of how to produce a clear message:
package main
import (
"fmt"
"os"
)
func main() {
path := "data/tasks.json"
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("no such file:", path)
return
}
if os.IsPermission(err) {
fmt.Println("no permission:", path)
return
}
fmt.Println("stat error:", err)
return
}
fmt.Println("ok")
}
Psychologically, this is an important upgrade: you stop perceiving errors as “something terrible”, and start treating them as logic branching. This is especially useful in CLI utilities: users need it short and clear.
6. Mode() and checking for a “regular file”
info.Mode() returns os.FileMode. Inside it, both access permissions and the type (file/directory/device, etc.) are encoded. At the beginning, the more important part is this: Mode() is a good diagnostic “label” that you can print to understand what the system thinks about your object.
Printing the mode often looks quite readable:
package main
import (
"fmt"
"os"
)
func main() {
info, err := os.Stat("data/tasks.json")
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Printf("mode=%v\n", info.Mode()) // mode=-rw-r--r--
}
If you want to make sure it is a “regular file”, you can use IsRegular():
package main
import (
"fmt"
"os"
)
func main() {
info, _ := os.Stat("data/tasks.json")
fmt.Println(info.Mode().IsRegular()) // true (for a regular file)
}
Here we do not dig into bit masks: today the task is simpler — read metadata and make basic decisions.
7. The “Stat → decision” pattern
When os.Stat becomes a familiar tool, code starts to look more predictable. It helps to keep a simple pattern in mind: first metadata, then action. This reduces the chance of “reading a directory as a file” and getting a mysterious error.
flowchart TD
A["You have a path (string)"] --> B["os.Stat(path)"]
B -->|err != nil| C["Handle the error: not exist / permission / other"]
B -->|err == nil| D["info.IsDir()?"]
D -->|true| E["It is a directory: choose different logic"]
D -->|false| F["It is a file: you can read/write/parse"]
8. Common mistakes when working with os.Stat and FileInfo
Mistake #1: using info without checking err.
os.Stat returns two values not out of greed, but because the file system is a fickle thing. If err != nil, then info must not be treated as a valid object. Good style is: check the error first, and only then read metadata fields.
Mistake #2: confusing metadata with contents.
Sometimes you want to “check that the file is not empty” and start “reading the first 10 lines”. But very often Size() is enough. Or you want to know “when the file changed” and you start parsing JSON and looking for an updated_at field — even though ModTime() already gives you the system modification time for the file.
Mistake #3: interpreting directory Size() as “folder size”.
A directory’s size is not the sum of file sizes inside it. If you need “folder size”, that is a different task: you need to list the contents and sum the sizes. If you use directory Size() as “folder size”, you will get numbers that sometimes look plausible but are actually misleading.
Mistake #4: printing an error without path context.
A message like "stat: permission denied" without the path quickly becomes a “guess where” quest. It is better to wrap the error including the path: fmt.Errorf("stat %q: %w", path, err). Then you have a human-readable trail while debugging.
Mistake #5: doing an “existence check” with Stat and then calling Stat again just to get metadata.
Sometimes people write: “if the file exists, OK”, and then call Stat a second time to get Size() and ModTime(). That is extra work. If you need metadata, a single call to os.Stat already gives it to you — just use info.
GO TO FULL VERSION