The remote control for your program
You are building a command-line tool. It needs to read a configuration file from the user's home directory. It needs to check if the user set a DEBUG flag in the environment. It needs to write a log file to disk. You are not building a web server that waits for HTTP requests. You are writing software that interacts directly with the machine running it.
The os package is the bridge between your Go code and the operating system. It provides a platform-independent interface to functionality like file handling, environment variables, process management, and user information. You write the code once using os, and Go handles the differences between Windows, Linux, and macOS under the hood.
The package name stands for "operating system." It is one of the most imported packages in the standard library. Almost every non-trivial Go program imports os at some point.
How os abstracts the machine
Operating systems disagree on almost everything. Windows uses backslashes for paths. Linux uses forward slashes. Windows uses different file permission models. Linux uses signals. macOS has case-insensitive filesystems by default.
Go hides these differences. When you call os.Open("config.json"), Go translates that call to the correct system call for the platform. On Linux, it might call open(). On Windows, it calls CreateFile(). You don't need to know the difference. You just get a *os.File back, or an error.
This abstraction is powerful. It means your code is portable. It also means you must respect the conventions of the package. The os package returns errors instead of panicking. It expects you to manage resources like file handles explicitly. It does not clean up after you unless you tell it to.
Minimal example: env vars and files
Start with the basics. Read an environment variable. Create a file. Write some data. Close the file. This pattern appears in almost every Go program that touches the filesystem.
package main
import (
"fmt"
"os"
)
// main is the entry point for the program.
func main() {
// os.Getenv returns the value of the environment variable.
// It returns an empty string if the variable is not set.
// This is a common pattern for optional configuration.
user := os.Getenv("USER")
if user == "" {
user = "World"
}
// os.Create creates the named file for reading and writing.
// It truncates the file if it already exists.
// Always check the error. File creation can fail due to permissions or disk space.
file, err := os.Create("hello.txt")
if err != nil {
// os.Stderr is the standard error stream.
// Printing errors to stderr keeps them separate from normal output.
fmt.Fprintf(os.Stderr, "Failed to create file: %v\n", err)
// os.Exit terminates the program immediately.
// It does not run deferred functions.
os.Exit(1)
}
// defer schedules file.Close to run when main returns.
// This ensures the file handle is released even if an error occurs later.
defer file.Close()
// WriteString writes the string to the file.
// It returns the number of bytes written and an error.
// We use _ to discard the byte count since we don't need it here.
_, err = file.WriteString(fmt.Sprintf("Hello, %s!\n", user))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to write to file: %v\n", err)
os.Exit(1)
}
fmt.Println("File created successfully.")
}
Walking through the code
The example demonstrates three core concepts of the os package.
Environment variables are accessed via os.Getenv. The function returns a string. If the variable does not exist, it returns an empty string. This behavior is convenient for optional settings but can be ambiguous. An empty string might mean the variable is unset, or it might mean the variable is set to an empty value. Use os.LookupEnv if you need to distinguish between these cases. LookupEnv returns a second boolean value indicating whether the variable was found.
File creation uses os.Create. This function creates a new file or truncates an existing one. It returns a *os.File and an error. The *os.File type implements the io.Reader and io.Writer interfaces. This means you can pass it to any function that expects a reader or writer. The error check is mandatory. If the directory does not exist, or if permissions are wrong, os.Create fails. The compiler rejects the program with declared and not used if you ignore the error return value.
Resource management relies on defer. File handles are a limited resource. The operating system limits how many files a process can have open. If you open files without closing them, you will eventually hit a limit and the program will crash. defer file.Close() schedules the close operation to run when the function returns. This is the standard Go pattern for cleanup. Place the defer immediately after the successful open or create call.
The os.Exit function terminates the program. It takes an exit code. Zero means success. Non-zero means failure. os.Exit is immediate. It does not run deferred functions. It does not run finalizers. It just stops. Use it only in main or when you need to abort the entire process instantly. In library code, return an error instead.
Realistic scenario: robust config loading
Real programs need to handle missing files, permission errors, and fallback logic. A configuration loader often checks the environment first, then falls back to a file. It needs to distinguish between "file not found" and "file exists but is unreadable."
The os package provides helper functions for error checking. os.IsNotExist checks if an error means the file or directory does not exist. os.IsPermission checks if the error is a permission problem. os.IsExist checks if the error means the file already exists. These functions wrap the underlying system errors into a portable interface.
package main
import (
"fmt"
"os"
)
// LoadConfig reads configuration from environment variables or a file.
// It prioritizes environment variables over file contents.
func LoadConfig(path string) (string, error) {
// os.LookupEnv returns the value and a boolean indicating existence.
// This allows us to check if the env var is explicitly set.
if val, ok := os.LookupEnv("APP_CONFIG"); ok {
// If the env var is set, use it and skip file reading.
// This is a common pattern for overriding config in CI/CD pipelines.
return val, nil
}
// os.ReadFile reads the entire file into memory.
// It was added in Go 1.16 to replace ioutil.ReadFile.
// It handles opening, reading, and closing the file in one call.
data, err := os.ReadFile(path)
if err != nil {
// os.IsNotExist checks if the error indicates the file is missing.
// This allows us to provide a default or a specific error message.
if os.IsNotExist(err) {
// Return a default config when the file is missing.
// This makes the program resilient to missing config files.
return "default_config", nil
}
// os.IsPermission checks if the error is due to access rights.
// This helps the user understand why the file couldn't be read.
if os.IsPermission(err) {
return "", fmt.Errorf("permission denied reading config: %w", err)
}
// Return the original error for any other failure.
// The %w verb wraps the error for further inspection.
return "", fmt.Errorf("failed to read config: %w", err)
}
// Convert the byte slice to a string.
// Trim any trailing newline characters that might be in the file.
config := string(data)
return config, nil
}
// main demonstrates loading config with fallback logic.
func main() {
config, err := LoadConfig("config.txt")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Loaded config: %s\n", config)
}
Pitfalls and compiler traps
The os package is straightforward, but it has traps. Knowing them saves debugging time.
os.Exit skips deferred functions. This is the most common mistake. If you have defer calls that clean up resources, send shutdown signals, or flush logs, os.Exit will not run them. The program terminates instantly. Use os.Exit only in main. In other functions, return an error and let the caller decide how to exit.
os.Open and os.Create return *os.File. You must close the file. Forgetting to close a file leaks a file descriptor. The operating system will eventually run out of descriptors and return "too many open files" errors. The compiler cannot catch this. You must use defer file.Close() or use os.ReadFile / os.WriteFile which handle closing internally.
Path separators vary by platform. Windows uses backslashes. Unix uses forward slashes. Go's os package accepts forward slashes on Windows, but it is safer to use the filepath package for path manipulation. filepath.Join uses the correct separator for the current OS. filepath.Base extracts the filename. filepath.Dir extracts the directory. Never concatenate paths with string concatenation. Use filepath.Join.
os.OpenFile is complex. It takes flags and permissions. The flags control how the file is opened. os.O_RDONLY opens for reading. os.O_WRONLY opens for writing. os.O_CREATE creates the file if it does not exist. os.O_TRUNC truncates the file. os.O_APPEND appends to the file. You can combine flags with the bitwise OR operator. The permissions argument is only used when creating a new file. It uses octal notation like 0644. The compiler rejects invalid permissions with invalid operation or constant overflows.
The os package is synchronous. Functions like os.ReadFile block until the operation completes. They do not take a context.Context. If you need to cancel a file operation, you cannot use context. You must manage cancellation at a higher level, or use non-blocking I/O patterns. Most file operations are fast enough that blocking is acceptable. For long-running operations, consider running them in a goroutine.
Decision matrix: os vs alternatives
The standard library provides several packages for OS interaction. Choosing the right one depends on your specific need.
Use os when you need to read environment variables, access process arguments, or perform basic file I/O. It is the primary interface for interacting with the operating system. Use os when you need to check file existence, permissions, or metadata via os.Stat.
Use filepath when you need to manipulate file paths. It handles platform-specific separators and path components correctly. Use filepath.Join to build paths. Use filepath.Base to get the filename. Use filepath.Dir to get the directory. Never use string concatenation for paths.
Use os/exec when you need to run external commands. It allows you to spawn processes, connect to their standard streams, and wait for them to complete. Use exec.Command to define the command. Use Command.Output to capture stdout. Use Command.Run to execute without capturing output.
Use os/user when you need to look up user or group information by name or ID. It provides a portable interface to system user databases. Use user.Current to get the current user. Use user.Lookup to find a user by name.
Use os/signal when you need to handle OS signals like SIGINT or SIGTERM. It allows you to register handlers for graceful shutdown. Use signal.Notify to register a channel for signals. Use signal.Stop to unregister.
Use os for environment variables and basic files. Use filepath for paths. Use os/exec for subprocesses. Use os/user for user info. Use os/signal for signals. Pick the package that matches the operation. Don't reach for os/exec to read a file. Don't reach for filepath to set an environment variable.
Where to go next
- How to Read a File Line by Line in Go
- Fix: "too many open files" in Go
- How to Use Filesystem Notifications in Go (fsnotify)
Defer is your insurance policy. Errors are values. Trust the standard library.