Os #
Every real-world Go application interacts with the operating system — reading configuration files, writing logs, accessing environment variables, receiving shutdown signals, or checking whether a path exists on the filesystem. The os package is the bridge between Go code and the operating system running it. It provides a uniform interface for all these operations, regardless of whether the program runs on Linux, macOS, or Windows. What makes os important isn’t just its capabilities, but also its consistent error handling approach — every operation that can fail returns an error that can be checked with os.IsNotExist, os.IsPermission, and similar functions. This article covers the entire os package: file operations, directories, environment variables, processes, and signals.
An Overview of the os Package #
The os package organizes its functions by what they operate on — files, directories, the environment, or the process itself.
flowchart TD
OS["package os"] --> File["File Operations"]
OS --> Dir["Directory Operations"]
OS --> Env["Environment & Process"]
OS --> Signal["OS Signals"]
OS --> Stdio["Standard I/O"]
File --> F1["os.Open / os.Create / os.OpenFile"]
File --> F2["os.ReadFile / os.WriteFile"]
File --> F3["os.Remove / os.Rename / os.Chmod"]
File --> F4["os.Stat / os.Lstat"]
Dir --> D1["os.Mkdir / os.MkdirAll"]
Dir --> D2["os.ReadDir / os.Getwd"]
Dir --> D3["os.Remove / os.RemoveAll"]
Dir --> D4["os.TempDir / os.MkdirTemp"]
Env --> E1["os.Getenv / os.Setenv / os.Environ"]
Env --> E2["os.Args — program arguments"]
Env --> E3["os.Exit / os.Getpid"]
Env --> E4["os.Hostname / os.Executable"]
Signal --> S1["os/signal.Notify"]
Signal --> S2["syscall.SIGINT / SIGTERM"]
Stdio --> IO1["os.Stdin / os.Stdout / os.Stderr"]
style OS fill:#4f86c6,color:#fff
style File fill:#e8f5e9
style Dir fill:#e3f2fd
style Env fill:#fff3e0
style Signal fill:#fce4ec
style Stdio fill:#f3e5f5Reading and Writing Files #
File operations are what you’ll do most often with os. Go provides two API levels: high-level functions (ReadFile/WriteFile) for simple cases, and os.File for full control.
ReadFile and WriteFile — The Easiest Way #
For files that fit in memory, os.ReadFile and os.WriteFile are the best choice — one line, no manual open/close needed.
package main
import (
"fmt"
"os"
)
func main() {
// Write a file all at once
content := []byte("Hello from Go!\nSecond line.\n")
err := os.WriteFile("output.txt", content, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write file: %v\n", err)
os.Exit(1)
}
// Read the file all at once
data, err := os.ReadFile("output.txt")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read file: %v\n", err)
os.Exit(1)
}
fmt.Printf("File content (%d bytes):\n%s", len(data), data)
}
Permission 0644 is the common convention for text files: the owner can read and write, group and others can only read. For executable files use 0755.
os.Open, os.Create, and os.OpenFile #
For more control — reading part of a file, appending, or specific access modes — use these functions:
flowchart LR
subgraph Shortcut["Shortcut Functions"]
Open["os.Open(path)\nread-only, O_RDONLY"]
Create["os.Create(path)\nwrite, O_RDWR|O_CREATE|O_TRUNC"]
end
subgraph Full["Full Control"]
OpenFile["os.OpenFile(path, flag, perm)"]
end
subgraph Flags["Common Flags"]
direction TB
F1["O_RDONLY — read only"]
F2["O_WRONLY — write only"]
F3["O_RDWR — read and write"]
F4["O_CREATE — create if it doesn't exist"]
F5["O_TRUNC — truncate when opened"]
F6["O_APPEND — append at the end"]
F7["O_EXCL — fail if it already exists"]
end
OpenFile --> Flags
style Shortcut fill:#e8f5e9
style Full fill:#e3f2fd
style Flags fill:#fff3e0// os.Open — read only
f, err := os.Open("config.txt")
if err != nil {
// check the error type
if os.IsNotExist(err) {
fmt.Println("file not found")
} else {
fmt.Fprintf(os.Stderr, "failed to open file: %v\n", err)
}
return
}
defer f.Close() // always defer Close after a successful Open
// os.Create — create a new file or truncate an existing one
f2, err := os.Create("report.txt")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create file: %v\n", err)
return
}
defer f2.Close()
fmt.Fprintln(f2, "Daily report")
// os.OpenFile — full control with flags
// Append to an existing file, create it if it doesn't exist
f3, err := os.OpenFile("app.log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open log: %v\n", err)
return
}
defer f3.Close()
fmt.Fprintln(f3, "new log entry")
// Create a new file, fail if it already exists (to avoid overwrites)
f4, err := os.OpenFile("data.json",
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if os.IsExist(err) {
fmt.Println("file already exists, skipping")
} else if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return
} else {
defer f4.Close()
fmt.Fprintln(f4, "{}")
}
Reading Part of a File — The io.Reader Interface #
os.File implements io.Reader, so it can be used with every function that accepts a reader — including bufio.Scanner for reading line by line:
import (
"bufio"
"fmt"
"os"
)
func readLineByLine(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("readLineByLine: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
number := 1
for scanner.Scan() {
fmt.Printf("%3d: %s\n", number, scanner.Text())
number++
}
// Check the scanner error — not just EOF
if err := scanner.Err(); err != nil {
return fmt.Errorf("readLineByLine: error while scanning: %w", err)
}
return nil
}
Writing to a File — The io.Writer Interface #
Likewise, os.File implements io.Writer, so fmt.Fprintf and bufio.Writer can be used directly:
import (
"bufio"
"fmt"
"os"
"time"
)
func writeReport(path string, data []string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("writeReport: %w", err)
}
defer f.Close()
// Use a bufio.Writer for better performance
// when writing many small lines
w := bufio.NewWriter(f)
fmt.Fprintf(w, "Report — %s\n", time.Now().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "%s\n", "===================")
for i, item := range data {
fmt.Fprintf(w, "%3d. %s\n", i+1, item)
}
// IMPORTANT: Flush must be called so the data is actually written to the file
// defer f.Close() does not automatically flush a bufio.Writer
if err := w.Flush(); err != nil {
return fmt.Errorf("writeReport: flush failed: %w", err)
}
return nil
}
If you use abufio.Writer, always callw.Flush()beforef.Close().defer f.Close()will not flush the buffer automatically — unflushed data is lost without any error. This is a commonly overlooked source of bugs because the program doesn’t report any error, but the written file is incomplete.
File Information — os.Stat and FileInfo #
os.Stat returns an fs.FileInfo containing file metadata without opening it: size, permissions, modification time, and whether it’s a directory.
info, err := os.Stat("config.yaml")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("file doesn't exist")
return
}
fmt.Fprintf(os.Stderr, "stat error: %v\n", err)
return
}
fmt.Println("Name:", info.Name()) // config.yaml
fmt.Println("Size:", info.Size(), "bytes") // 1024
fmt.Println("Permission:", info.Mode()) // -rw-r--r--
fmt.Println("Directory?", info.IsDir()) // false
fmt.Println("Modified:", info.ModTime().Format("2006-01-02 15:04:05"))
The Difference Between Stat and Lstat #
flowchart TD
A["Target path"] --> B{"Is it a\nsymlink?"}
B -- Not a symlink --> C["os.Stat and os.Lstat\nreturn the same\nfile info"]
B -- Symlink --> D{"Which function\nis used?"}
D -- "os.Stat()" --> E["Follow the symlink\n→ info of the target file"]
D -- "os.Lstat()" --> F["Don't follow the symlink\n→ info of the symlink itself"]
E --> G["info.Mode().IsRegular() → true\nif the target is a regular file"]
F --> H["info.Mode()&fs.ModeSymlink != 0\n→ true, this is a symlink"]// Stat — follow the symlink, check the target file
info, _ := os.Stat("link-to-file.txt")
// Lstat — don't follow the symlink, check the symlink itself
infoLink, _ := os.Lstat("link-to-file.txt")
fmt.Println(infoLink.Mode()&os.ModeSymlink != 0) // true if it's a symlink
Checking Whether a File Exists #
// ANTI-PATTERN: check existence then open — there's a race condition
if _, err := os.Stat(path); err == nil {
f, err := os.Open(path) // the file could be deleted between Stat and Open!
// ...
}
// CORRECT: open directly, check the error
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
// handle the missing file
return
}
// handle other errors
return
}
defer f.Close()
Directory Operations #
Creating Directories #
// Mkdir — create one directory, fails if the parent doesn't exist
err := os.Mkdir("output", 0755)
if err != nil && !os.IsExist(err) {
fmt.Fprintf(os.Stderr, "failed to create dir: %v\n", err)
}
// MkdirAll — create all needed directories at once
// like "mkdir -p" in the shell
err = os.MkdirAll("output/2024/03/reports", 0755)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create dir tree: %v\n", err)
}
Reading Directory Contents #
// ReadDir — read all entries in a directory, already sorted by name
entries, err := os.ReadDir(".")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read dir: %v\n", err)
return
}
for _, entry := range entries {
kind := "file"
if entry.IsDir() {
kind = "dir "
}
// Info() returns FileInfo — can be nil if the file changed
info, err := entry.Info()
if err != nil {
continue
}
fmt.Printf("[%s] %-30s %8d bytes\n",
kind, entry.Name(), info.Size())
}
Recursive Traversal with os.WalkDir #
os.WalkDir is the most efficient way to traverse an entire directory recursively. It’s more efficient than filepath.Walk because it doesn’t call Lstat for every entry.
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
func countFiles(root string) (fileCount, dirCount int, totalSize int64) {
os.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Continue even if one entry has an error
fmt.Fprintf(os.Stderr, "skip %s: %v\n", path, err)
return nil
}
if d.IsDir() {
// Skip hidden directories
if strings.HasPrefix(d.Name(), ".") && path != root {
return fs.SkipDir
}
dirCount++
return nil
}
fileCount++
info, err := d.Info()
if err == nil {
totalSize += info.Size()
}
return nil
})
return
}
// Collect all .go files in a project
func findGoFiles(root string) ([]string, error) {
var files []string
err := os.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && filepath.Ext(path) == ".go" {
files = append(files, path)
}
return nil
})
return files, err
}
Temporary Files #
// MkdirTemp — create a temporary directory (deleted when done)
tmpDir, err := os.MkdirTemp("", "myapp-*")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create tmp dir: %v\n", err)
return
}
defer os.RemoveAll(tmpDir) // clean up when done
// CreateTemp — create a temporary file
tmpFile, err := os.CreateTemp(tmpDir, "data-*.json")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create tmp file: %v\n", err)
return
}
defer os.Remove(tmpFile.Name()) // remove the file when done
defer tmpFile.Close()
fmt.Fprintf(tmpFile, `{"status": "ok"}`)
fmt.Println("Temporary file:", tmpFile.Name())
// /tmp/myapp-123456789/data-987654321.json
Environment Variables #
Environment variables are the standard way to configure an application without changing code or configuration files. This is a very common practice in container and cloud deployments.
flowchart LR
subgraph Sources["Config Sources"]
EnvVar["Environment Variable\nDB_HOST=localhost"]
DotEnv[".env file\n(with the godotenv library)"]
Args["os.Args\n--host=localhost"]
end
subgraph App["Go Application"]
GetEnv["os.Getenv('DB_HOST')"]
LookupEnv["os.LookupEnv('DB_HOST')"]
Environ["os.Environ()\nall env vars"]
end
subgraph Config["Config Struct"]
C["Config{\n Host: 'localhost'\n Port: 5432\n}"]
end
Sources --> App --> Config
style Sources fill:#e3f2fd
style App fill:#e8f5e9
style Config fill:#fff3e0// Getenv — returns an empty string if absent
host := os.Getenv("DB_HOST")
if host == "" {
host = "localhost" // default value
}
// ANTI-PATTERN: can't distinguish "absent" vs "deliberately empty"
port := os.Getenv("DB_PORT")
if port == "" {
port = "5432" // could be wrong if DB_PORT="" is intentional
}
// CORRECT: LookupEnv — distinguishes "absent" from "empty value"
portStr, exists := os.LookupEnv("DB_PORT")
if !exists {
portStr = "5432" // default only when truly absent
}
// Setenv — set an environment variable for this process (and child processes)
os.Setenv("APP_ENV", "production")
// Unsetenv — remove an environment variable
os.Unsetenv("DEBUG_MODE")
// Environ — get all environment variables as a []string of "KEY=VALUE"
for _, env := range os.Environ() {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
fmt.Printf("%-20s = %s\n", parts[0], parts[1])
}
}
// Clearenv — remove all environment variables (careful!)
// os.Clearenv() // don't use this casually
Pattern: Config from the Environment #
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
DBHost string
DBPort int
DBName string
DBUser string
DBPassword string
MaxConn int
Timeout time.Duration
Debug bool
}
func configFromEnv() (*Config, error) {
cfg := &Config{
DBHost: getEnvDefault("DB_HOST", "localhost"),
DBName: getEnvDefault("DB_NAME", "myapp"),
DBUser: getEnvDefault("DB_USER", "postgres"),
MaxConn: 10,
Timeout: 30 * time.Second,
}
// Port — needs conversion to int
portStr := getEnvDefault("DB_PORT", "5432")
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid DB_PORT: %w", err)
}
cfg.DBPort = port
// Password — required
password, exists := os.LookupEnv("DB_PASSWORD")
if !exists || password == "" {
return nil, fmt.Errorf("DB_PASSWORD must be set")
}
cfg.DBPassword = password
// MaxConn — optional with a default
if maxConnStr, exists := os.LookupEnv("DB_MAX_CONN"); exists {
maxConn, err := strconv.Atoi(maxConnStr)
if err != nil {
return nil, fmt.Errorf("invalid DB_MAX_CONN: %w", err)
}
cfg.MaxConn = maxConn
}
// Debug flag
cfg.Debug = os.Getenv("APP_DEBUG") == "true" ||
os.Getenv("APP_DEBUG") == "1"
return cfg, nil
}
func getEnvDefault(key, defaultVal string) string {
if val, exists := os.LookupEnv(key); exists {
return val
}
return defaultVal
}
Program Arguments — os.Args #
os.Args is a slice of strings containing the arguments given when the program runs. os.Args[0] is the program name itself.
// Program: ./myapp --env production --port 8080 --debug
fmt.Println("Program:", os.Args[0]) // ./myapp
fmt.Println("All args:", os.Args[1:]) // [--env production --port 8080 --debug]
fmt.Println("Arg count:", len(os.Args)-1) // 5
// Manual parsing — for simple programs
func parseArgs() map[string]string {
args := make(map[string]string)
a := os.Args[1:]
for i := 0; i < len(a); i++ {
if strings.HasPrefix(a[i], "--") {
key := strings.TrimPrefix(a[i], "--")
if i+1 < len(a) && !strings.HasPrefix(a[i+1], "--") {
args[key] = a[i+1]
i++
} else {
args[key] = "true" // a flag without a value
}
}
}
return args
}
For more complex argument parsing, use theflagpackage from the standard library (covered in its own article) or third-party libraries likecobra(for fuller CLI tools with subcommands).os.Argsis usually accessed directly only for very simple programs.
Process Information #
// This process's PID
fmt.Println("PID:", os.Getpid())
// The parent process's PID
fmt.Println("PPID:", os.Getppid())
// Machine hostname
hostname, err := os.Hostname()
if err == nil {
fmt.Println("Host:", hostname)
}
// Path of this program's executable
execPath, err := os.Executable()
if err == nil {
fmt.Println("Executable:", execPath)
fmt.Println("Dir:", filepath.Dir(execPath))
}
// Current working directory
wd, err := os.Getwd()
if err == nil {
fmt.Println("CWD:", wd)
}
// Change the working directory
err = os.Chdir("/tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "chdir failed: %v\n", err)
}
Handling OS Signals #
OS signals are the communication mechanism between the operating system and a process. The most important signals to handle in production applications are SIGINT (Ctrl+C) and SIGTERM (shutdown from an orchestrator like Kubernetes). Without proper handling, the application dies immediately upon receiving these signals — database connections aren’t closed, in-flight requests are cut off, and data can be corrupted.
sequenceDiagram
participant OS as Operating System
participant Signal as os/signal
participant App as Go Application
participant DB as Database/Resources
OS->>Signal: SIGTERM (from K8s, docker stop, etc.)
Signal->>App: channel sigChan <- syscall.SIGTERM
App->>App: receive the signal, start shutdown
App->>DB: close the database connection
App->>App: wait for active requests to finish
App->>App: os.Exit(0)
Note over App,DB: Graceful shutdown — no data lostpackage main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// Set up a channel to capture signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan,
syscall.SIGINT, // Ctrl+C
syscall.SIGTERM, // kill / docker stop / kubernetes
)
// Run the application in a separate goroutine
done := make(chan struct{})
go func() {
defer close(done)
runServer()
}()
// Block until a signal is received
sig := <-sigChan
fmt.Printf("\nReceived signal: %v\n", sig)
fmt.Println("Starting graceful shutdown...")
// Give time for cleanup
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Do the cleanup
if err := cleanupResources(ctx); err != nil {
fmt.Fprintf(os.Stderr, "cleanup error: %v\n", err)
os.Exit(1)
}
fmt.Println("Shutdown complete")
os.Exit(0)
}
func cleanupResources(ctx context.Context) error {
// Simulate cleanup: close DB connections, flush log buffers, etc.
fmt.Println("Closing database connections...")
select {
case <-time.After(2 * time.Second): // simulate the cleanup operation
fmt.Println("Database connections closed")
return nil
case <-ctx.Done():
return fmt.Errorf("cleanup timeout: %w", ctx.Err())
}
}
Error Handling in os — Checking Error Types #
The os package provides helper functions for checking the type of errors returned by file and directory operations. This matters because the right action differs depending on the error type.
flowchart TD
E["error from os.*"] --> Check{"Check the error\ntype"}
Check --> IsNotExist["os.IsNotExist(err)\nor errors.Is(err, fs.ErrNotExist)"]
Check --> IsExist["os.IsExist(err)\nor errors.Is(err, fs.ErrExist)"]
Check --> IsPerm["os.IsPermission(err)\nor errors.Is(err, fs.ErrPermission)"]
Check --> IsTimeout["os.IsTimeout(err)"]
Check --> Other["other error\nlog and return"]
IsNotExist --> A1["Create the file/dir\nor return not found"]
IsExist --> A2["Skip or\nrename"]
IsPerm --> A3["Ask for permission\nor run as root"]
IsTimeout --> A4["Retry with\nbackoff"]
style E fill:#fce4ec
style Check fill:#4f86c6,color:#fff
style IsNotExist fill:#e8f5e9
style IsExist fill:#e3f2fd
style IsPerm fill:#fff3e0
style IsTimeout fill:#f3e5f5func loadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
switch {
case os.IsNotExist(err):
// File doesn't exist — use the default config
fmt.Printf("config %s not found, using defaults\n", path)
return defaultConfig(), nil
case os.IsPermission(err):
// No access — this is a serious error
return nil, fmt.Errorf("no read permission for %s — try running with sudo: %w", path, err)
default:
// Other errors — wrap and return
return nil, fmt.Errorf("failed to read config %s: %w", path, err)
}
}
return data, nil
}
// The modern version uses errors.Is (Go 1.13+)
// errors.Is is more robust because it works with wrapped errors
import "io/fs"
func checkFile(path string) {
_, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("doesn't exist")
} else if errors.Is(err, fs.ErrPermission) {
fmt.Println("no permission")
} else if err != nil {
fmt.Println("other error:", err)
} else {
fmt.Println("exists")
}
}
Other File Operations #
Rename, Copy, and Remove #
// Rename — can also be used to move a file
err := os.Rename("old.txt", "new.txt")
// Or move to another directory
err = os.Rename("tmp/data.json", "output/data.json")
// Remove — delete a file or an empty directory
err = os.Remove("not-needed.txt")
if err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "failed to remove: %v\n", err)
}
// RemoveAll — delete a directory and everything in it (like rm -rf)
err = os.RemoveAll("tmp/")
// BE CAREFUL: there's no undo for RemoveAll!
// Chmod — change file permissions
err = os.Chmod("script.sh", 0755) // make it executable
// Truncate — cut a file down to a certain size
err = os.Truncate("file.txt", 0) // empty the file without deleting it
Copying Files #
The os package doesn’t provide a direct copy function — Go deliberately separates read and write operations. The correct pattern uses io.Copy:
import (
"io"
"os"
)
func copyFile(src, dst string) error {
// Open the source file
source, err := os.Open(src)
if err != nil {
return fmt.Errorf("copyFile: open source: %w", err)
}
defer source.Close()
// Create the destination file
dest, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copyFile: create destination: %w", err)
}
defer dest.Close()
// Copy the content
bytesCopied, err := io.Copy(dest, source)
if err != nil {
return fmt.Errorf("copyFile: copy: %w", err)
}
// Copy the permissions from the source file
sourceInfo, err := source.Stat()
if err == nil {
os.Chmod(dst, sourceInfo.Mode())
}
fmt.Printf("Copied %d bytes from %s to %s\n", bytesCopied, src, dst)
return nil
}
Production Usage Patterns #
Atomic Writes — Writing Without Corruption Risk #
Writing directly to the target file is risky: if the program crashes mid-write, the file becomes corrupted. The atomic write pattern uses a temporary file as a buffer:
flowchart LR
A["New data"] --> B["Write to\nfile.tmp"]
B --> C{"Write\nsuccessful?"}
C -- Yes --> D["os.Rename\nfile.tmp → file.json"]
C -- No --> E["os.Remove\nfile.tmp"]
D --> F["file.json\nalways valid"]
E --> G["old file.json\nstays intact"]
style D fill:#e8f5e9
style E fill:#fce4ec
style F fill:#e8f5e9
style G fill:#e3f2fdfunc writeAtomic(path string, data []byte) error {
// Write to a temporary file in the same directory
// (important: must be on the same filesystem for an atomic rename)
dir := filepath.Dir(path)
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("writeAtomic: create tmp: %w", err)
}
tmpPath := tmpFile.Name()
// Make sure the tmp file is cleaned up on error
defer func() {
tmpFile.Close()
os.Remove(tmpPath) // no-op if the rename succeeded
}()
// Write the data to the temporary file
if _, err := tmpFile.Write(data); err != nil {
return fmt.Errorf("writeAtomic: write: %w", err)
}
// Sync to disk before the rename
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("writeAtomic: sync: %w", err)
}
// Rename — an atomic operation on the same filesystem
// The old file is never in a partial state from a reader's perspective
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("writeAtomic: rename: %w", err)
}
return nil
}
Making Sure a Directory Exists Before Writing #
func ensureDirExists(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("ensureDirExists %s: %w", dir, err)
}
return nil
}
func writeFile(path string, data []byte) error {
if err := ensureDirExists(path); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
Reading Config Files with Fallbacks #
// A common config priority order in production applications
func loadConfig() (*Config, error) {
locations := []string{
os.Getenv("CONFIG_PATH"), // explicitly from the env
"./config.yaml", // the current directory
filepath.Join(os.Getenv("HOME"),
".config/myapp/config.yaml"), // the user's home directory
"/etc/myapp/config.yaml", // system-wide config
}
for _, path := range locations {
if path == "" {
continue
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue // try the next location
}
return nil, fmt.Errorf("failed to read config %s: %w", path, err)
}
fmt.Printf("Using config from: %s\n", path)
return parseConfig(data)
}
fmt.Println("No config found, using default values")
return configDefault(), nil
}
When to Switch to Alternatives #
Keep using os if:
✓ Reading/writing files and directories directly
✓ Accessing environment variables and program arguments
✓ Handling OS signals for graceful shutdown
✓ Process information (PID, hostname, executable path)
✓ Filesystem operations: rename, remove, chmod, stat
Consider io/fs and fs.FS if:
✗ You want a filesystem abstraction that can be mocked in testing
✗ Working with embedded files (go:embed)
✗ Creating abstractions usable with virtual filesystems
Consider path/filepath if:
✗ Cross-platform path manipulation and joining
✗ Finding files with glob patterns
✗ Converting relative paths to absolute
Consider bufio if:
✗ Reading large files line by line
✗ You need buffering to improve I/O performance
✗ Parsing complex text formats line by line
Consider external libraries if:
✗ Watching file changes in real time → fsnotify
✗ Very complex filesystem operations → afero (mock-friendly)
Summary #
os.ReadFile/os.WriteFileare the fastest way to read/write small files all at once — no manual open/close needed, ideal for configs and simple data.os.OpenFilewith flags gives full control:O_APPENDfor logs,O_EXCLto avoid overwrites,O_RDWRfor reading and writing at once.- Always
defer f.Close()right after a successfulos.Openoros.Create— don’t delay this declaration.- If you use a
bufio.Writer, alwaysFlush()before the file is closed —defer f.Close()doesn’t flush the buffer automatically.os.LookupEnvis better thanos.Getenvwhen you need to distinguish between a missing variable and a deliberately empty one.os.MkdirAllis the equivalent ofmkdir -p— use it instead ofos.Mkdirto avoid errors when the parent directory doesn’t exist yet.- The atomic write pattern (write to tmp then rename) ensures the target file is never in a partial state — important for config files and critical data.
- Handle the SIGINT and SIGTERM signals for graceful shutdown — this is a mandatory standard for applications running in containers or the cloud.
os.IsNotExist/errors.Is(err, fs.ErrNotExist)to check filesystem error types — don’t just print the error, check its type and handle it differently.os.WalkDiris more efficient thanfilepath.Walkfor recursive directory traversal because it avoids extraLstatcalls for every entry.