The gap between checking and doing
You are writing a CLI tool that loads a configuration file before starting a server. The script checks if the file exists. It does. The script opens the file. The file is gone. Or maybe you open the file just to verify it is there, close it immediately, and then open it again to read it. You just burned two file descriptors and two system calls for a single logical step.
Filesystems are not static databases. They are live, mutable state that other processes, cron jobs, or users can change at any millisecond. Checking for existence and then acting on that check creates a tiny window where reality can shift. Go gives you the tools to inspect the filesystem, but it also pushes you toward patterns that survive that shifting reality.
Checking existence is a snapshot. The filesystem is a moving target.
How the filesystem answers
When you ask a program whether a file exists, the program does not look at the file itself. It asks the operating system for metadata. In Go, that call is os.Stat. Under the hood, it translates to a stat or lstat system call. The kernel looks up the path in the directory tree, finds the inode, and returns a block of information: size, permissions, modification time, and whether the entry points to a regular file or a directory. Crucially, it does not open the file. It does not allocate a file descriptor. It just reads the directory entry.
If the path does not resolve, the kernel returns an error code. Go wraps that code in an error interface. The standard library provides os.IsNotExist to test whether that error matches the "file not found" condition. This function checks the underlying OS error against a known set of codes, so it works consistently across Linux, macOS, and Windows.
You do not need to memorize error codes. You just need to know that Go treats errors as values to be inspected, not exceptions to be caught.
Handle the error first. The happy path waits.
The minimal existence check
Here is the standard pattern for verifying a file exists without opening it.
package main
import (
"fmt"
"os"
)
func main() {
path := "config.yaml"
// Stat returns metadata without opening the file descriptor
_, err := os.Stat(path)
if os.IsNotExist(err) {
// Path does not resolve to any filesystem entry
fmt.Println("missing")
return
}
if err != nil {
// Permission denied, broken symlink, or I/O failure
fmt.Printf("cannot stat: %v\n", err)
return
}
// No error means the path exists
fmt.Println("found")
}
The code discards the FileInfo return value with an underscore. That is intentional. When you only care about existence, the metadata is noise. The underscore tells the compiler you considered the return value and chose to drop it. Use it sparingly with errors, but it is perfectly idiomatic here.
The error handling follows the standard Go convention. You check for the specific condition you care about first. Then you check for any other error. Then you proceed. The if err != nil block looks verbose compared to try-catch blocks in other languages. The community accepts the boilerplate because it forces you to acknowledge the unhappy path at the call site. You cannot accidentally swallow a permission error or a disk failure.
Metadata tells you what it is. Opening tells you what you can do with it.
What happens under the hood
When the compiler sees os.Stat(path), it resolves the call to the os package. The function signature returns two values: os.FileInfo and error. The error type is an interface, which means the actual value could be a wrapped error, a typed error like *os.PathError, or a custom implementation. os.IsNotExist does not compare pointers. It unwraps the error chain and checks the underlying syscall.Errno against the platform-specific "no such file or directory" constant.
If you pass a directory path to os.Stat, it succeeds. The function does not distinguish between files and directories by default. It only confirms that something lives at that path. If you need to know what kind of thing it is, you must keep the FileInfo return value and inspect its mode bits.
The compiler rejects the program with cannot use _ (untyped blank identifier) as os.FileInfo value in assignment if you try to assign the first return value to a typed variable while discarding it incorrectly. Stick to the blank identifier when you genuinely do not need the metadata.
Trust the error interface. Unwrap when you need specifics, ignore when you do not.
Distinguishing files from directories
Real programs rarely just check existence. They usually need to know whether they are about to read a text file, traverse a directory, or follow a symlink. The FileInfo struct exposes methods that interpret the raw mode bits returned by the kernel.
package main
import (
"fmt"
"os"
)
func inspect(path string) {
// Keep the FileInfo to examine the entry type
fi, err := os.Stat(path)
if os.IsNotExist(err) {
fmt.Println("path missing")
return
}
if err != nil {
fmt.Printf("stat failed: %v\n", err)
return
}
// Mode bits determine whether the entry is a file, dir, or symlink
if fi.Mode().IsRegular() {
fmt.Println("regular file")
} else if fi.IsDir() {
fmt.Println("directory")
} else {
fmt.Println("other (symlink, socket, device)")
}
}
The IsRegular method checks that the mode bits match a standard file and exclude directories, symlinks, and special devices. IsDir does the opposite. If you are building a tool that processes files recursively, you will call IsDir to decide whether to descend into a subdirectory or read the contents.
Symlinks deserve a quick note. os.Stat follows symlinks. If you point it at a symbolic link, it returns the metadata of the target. If you need to inspect the link itself, use os.Lstat instead. The naming follows the Unix convention: lstat for link stat.
Metadata tells you what it is. Opening tells you what you can do with it.
The race condition trap
Checking for existence and then acting on that check introduces a time-of-check-to-time-of-use (TOCTOU) vulnerability. The window is small, but it is real. Between the moment os.Stat returns success and the moment you call os.Open or os.Create, another process can delete the file, replace it with a directory, or change its permissions. Your program will then panic or fail with an unexpected error.
If you attempt to open a file that vanished between the check and the call, the runtime returns a plain error like open config.yaml: no such file or directory. If you try to create a file that another process just created, you get open config.yaml: file already exists. These are not compiler errors. They are runtime signals that the filesystem state changed.
The idiomatic fix is to skip the check entirely. Attempt the operation directly. Handle the error if it fails. This pattern is called "optimistic concurrency" in filesystem code. You assume the operation will succeed, and you branch only when it does not.
package main
import (
"fmt"
"os"
)
func writeConfig(path string) {
// Open with create flag. Fails if file already exists.
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if os.IsExist(err) {
// Another process beat us to it
fmt.Println("config already exists")
return
}
if err != nil {
fmt.Printf("cannot create: %v\n", err)
return
}
// Close the file when done
defer f.Close()
fmt.Println("created successfully")
}
The os.O_EXCL flag is the key. It tells the kernel to fail if the file already exists. Combined with os.O_CREATE, it becomes an atomic check-and-create operation. No window for races. No need to call os.Stat first.
Don't check before you act. Act, then handle the fallout.
When to check and when to just try
Filesystem operations in Go follow a simple rule: prefer direct action over preliminary inspection. Use the right tool for the exact shape of your problem.
Use os.Stat when you need to verify a file exists before reading it and you control the environment. Use os.Open or os.ReadFile directly when you are about to read immediately and can handle a missing-file error gracefully. Use os.IsNotExist and os.IsExist when you need to branch logic based on filesystem state after an operation fails. Use os.OpenFile with os.O_CREATE|os.O_EXCL when you need to guarantee exclusive creation without a race window. Use os.Lstat when you must inspect symbolic links without following them.
The filesystem does not pause for your logic. Design around the race.