IO & File Operations
CLI tools live on file operations: reading input, writing output, processing files, walking directories. Go's standard library provides everything you need. The os package handles files, bufio handles buffered IO, filepath handles cross-platform paths, and io connects them all through the Reader/Writer interfaces.
Reading Files
os.ReadFile: Small Files
For files that fit comfortably in memory:
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
// data is []byte
os.Open + bufio.Scanner: Line by Line
For large files or when you need to process line by line:
f, err := os.Open("access.log")
if err != nil {
return fmt.Errorf("opening log: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text() // string, without newline
if strings.Contains(line, "ERROR") {
fmt.Printf("Line %d: %s\n", lineNum, line)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanning log: %w", err)
}
Handling Long Lines
The default scanner buffer is 64KB. For files with longer lines:
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
Reading with a Specific Delimiter
bufio.Scanner defaults to splitting on newlines. Use a custom split function for other delimiters:
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanWords) // split on whitespace
for scanner.Scan() {
word := scanner.Text()
fmt.Println(word)
}
Writing Files
os.WriteFile: Simple Writes
data := []byte("Hello, World!\n")
err := os.WriteFile("output.txt", data, 0644)
if err != nil {
return fmt.Errorf("writing file: %w", err)
}
The 0644 is the file permission: owner read/write, group and others read-only.
os.Create + Buffered Writing
For larger outputs or when you build content incrementally:
f, err := os.Create("report.csv")
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer f.Close()
w := bufio.NewWriter(f)
defer w.Flush() // important: flush buffered data
for _, record := range records {
fmt.Fprintf(w, "%s,%d,%f\n", record.Name, record.Count, record.Value)
}
Always call Flush on a bufio.Writer. Without it, the last chunk of data stays in the buffer and never reaches the file.
Appending to Files
f, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "%s: %s\n", time.Now().Format(time.RFC3339), message)
filepath: Cross-Platform Paths
Never build file paths with string concatenation. Use filepath:
// Join path components (uses correct separator for OS)
path := filepath.Join("data", "users", "config.json")
// Linux: "data/users/config.json"
// Windows: "data\users\config.json"
// Get directory and filename
dir := filepath.Dir("/home/user/docs/report.pdf") // "/home/user/docs"
base := filepath.Base("/home/user/docs/report.pdf") // "report.pdf"
ext := filepath.Ext("report.pdf") // ".pdf"
// Remove extension
name := strings.TrimSuffix("report.pdf", filepath.Ext("report.pdf")) // "report"
// Resolve to absolute path
abs, err := filepath.Abs("../config.json")
// Clean up messy paths
clean := filepath.Clean("./data/../data/./users/") // "data/users"
Working with stdin & stdout
CLI tools should work with pipes. Read from os.Stdin, write to os.Stdout:
func main() {
inputFile := flag.String("input", "-", "input file (- for stdin)")
flag.Parse()
var reader io.Reader
if *inputFile == "-" {
reader = os.Stdin
} else {
f, err := os.Open(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer f.Close()
reader = f
}
// Process input from either file or stdin
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
processLine(scanner.Text())
}
}
This lets your tool work both ways:
cat data.txt | mytool
mytool -input data.txt
Always write errors to os.Stderr, not os.Stdout. This keeps stdout clean for piping:
fmt.Fprintln(os.Stderr, "warning: skipping malformed line")
Temp Files & Directories
Use os.CreateTemp and os.MkdirTemp for temporary storage:
// Temp file in system temp dir
tmpFile, err := os.CreateTemp("", "myapp-*.json")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name()) // clean up when done
defer tmpFile.Close()
fmt.Fprintln(tmpFile, `{"status": "processing"}`)
The * in the pattern is replaced with a random string. The first argument is the directory (empty string means system default).
// Temp directory
tmpDir, err := os.MkdirTemp("", "myapp-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir) // clean up entire directory
outputPath := filepath.Join(tmpDir, "result.csv")
Walking Directory Trees
filepath.WalkDir
func findGoFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err // permission error, etc.
}
if d.IsDir() && d.Name() == "vendor" {
return filepath.SkipDir // skip vendor directory
}
if filepath.Ext(path) == ".go" {
files = append(files, path)
}
return nil
})
return files, err
}
os.ReadDir: One Level
When you only need the immediate children:
entries, err := os.ReadDir(".")
if err != nil {
return err
}
for _, entry := range entries {
info, _ := entry.Info()
fmt.Printf("%s %8d %s\n",
info.Mode(), info.Size(), entry.Name(),
)
}
filepath.Glob: Pattern Matching
matches, err := filepath.Glob("data/*.csv")
// matches: ["data/users.csv", "data/orders.csv"]
File Permissions
Go uses Unix-style permission bits:
// Owner: read/write, Group: read, Others: read
os.WriteFile("config.json", data, 0644)
// Owner: read/write/execute, Group: read/execute, Others: read/execute
os.Mkdir("bin", 0755)
// Owner only
os.WriteFile("secrets.env", data, 0600)
Check if a file exists:
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
fmt.Println("config file not found")
}
Copying Files
Go has no os.Copy. Use io.Copy with readers and writers:
func copyFile(src, dst string) error {
source, err := os.Open(src)
if err != nil {
return err
}
defer source.Close()
dest, err := os.Create(dst)
if err != nil {
return err
}
defer dest.Close()
_, err = io.Copy(dest, source)
return err
}
io.Copy streams data without loading the entire file into memory.
Common Pitfalls
- Not closing files. Always
defer f.Close()immediately after opening. Leaked file descriptors crash long-running programs. - Forgetting to flush bufio.Writer. Buffered writers hold data in memory. Without
Flush, the last chunk is lost. - Building paths with string concatenation.
"dir/" + filenamebreaks on Windows. Always usefilepath.Join. - Reading entire large files into memory. Use
bufio.Scannerorio.Copyfor large files.os.ReadFileis only for files that fit comfortably in RAM. - Writing errors to stdout. Errors on stdout corrupt piped output. Always use
os.Stderrfor error messages. - Not handling scanner buffer overflow. Lines longer than 64KB silently fail. Set a larger buffer for files with long lines.
- Using os.Remove on temp directories. Use
os.RemoveAllfor directories.
Key Takeaways
os.ReadFileandos.WriteFilehandle small files.bufio.Scannerandbufio.Writerhandle large files.- Always use
filepath.Joinfor cross-platform path construction. - CLI tools should read from stdin by default and write errors to stderr.
- Use
os.CreateTempandos.MkdirTempfor temporary files, always cleaning up withdefer. filepath.WalkDirtraverses directory trees.filepath.SkipDirprunes branches.io.Copystreams data between any Reader and Writer without loading everything into memory.- Always close files, flush buffers, and clean up temporary resources.