{} The Go Reference

System · Stdlib · Beginner

Files & os

Reading and writing files with os — whole-file vs streaming, OpenFile flags and permission bits, directories and FileInfo, portable paths via path/filepath (and filepath.WalkDir), and the os entry points (args, env, std streams, Exit).

System Beginner ⏱ 8 min read Complete

🗄️ Analogy

The os package is the doorway between your program and the machine. os.ReadFile is grabbing a whole document off the shelf at once; os.Open hands you a bookmark (*os.File) so you can read page by page. os.Args, os.Getenv, and os.Stdin/Stdout/Stderr are the doorway’s other openings — to the command line, the environment, and the terminal.

Whole files vs. streaming

For small files, read or write everything in one call. os.ReadFile returns a []byte; os.WriteFile takes the bytes plus a permission mode (0644 = owner read/write, everyone else read):

wholefile.go — editable & runnable
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
// Build a portable path under the OS temp dir.
path := filepath.Join(os.TempDir(), "greeting.txt")

// Write the whole file at once with 0644 permissions.
if err := os.WriteFile(path, []byte("hello\nfrom os\n"), 0644); err != nil {
	fmt.Println("write error:", err)
	return
}

// Read it all back into memory.
b, err := os.ReadFile(path)
if err != nil {
	fmt.Println("read error:", err)
	return
}

fmt.Printf("base: %s\n", filepath.Base(path)) // greeting.txt
fmt.Printf("ext:  %s\n", filepath.Ext(path))  // .txt
fmt.Printf("bytes read: %d\n", len(b))
fmt.Print(string(b))

os.Remove(path) // clean up
}

When a file is large — or you process it one line at a time — don’t load it all. os.Open returns an *os.File (an io.Reader); always defer f.Close() so the handle is released even on an early return. Wrap it in a bufio.Scanner to walk it line by line:

graph LR
F["os.Open(path)"] --> FILE["*os.File<br/>(io.Reader)"]
FILE --> SC["bufio.Scanner"]
SC -->|"Scan() / Text()"| L["one line at a time"]
F -.->|"defer"| C["f.Close()"]

The same bufio.Scanner pattern reads from any io.Reader — a file, os.Stdin, or a strings.Reader. Here it scans an in-memory reader so the output is fully deterministic:

scan.go — editable & runnable
package main

import (
"bufio"
"fmt"
"strings"
)

func main() {
// In a real program this would be a *os.File from os.Open;
// any io.Reader works the same way.
data := "alpha\nbeta\ngamma"
scanner := bufio.NewScanner(strings.NewReader(data))

lineNo := 0
for scanner.Scan() {
	lineNo++
	fmt.Printf("%d: %s\n", lineNo, scanner.Text())
}
if err := scanner.Err(); err != nil {
	fmt.Println("scan error:", err)
}
fmt.Println("total lines:", lineNo)
}

🐹 Scanner errors hide at the end

scanner.Scan() returns false both at end-of-file and on a read error — the loop looks identical either way. After the loop, always check scanner.Err(); a silent failure (a broken pipe, a too-long line) is otherwise invisible. By default bufio.Scanner also caps lines at 64KB; for longer lines call scanner.Buffer(...).

Open flags and permission bits

os.ReadFile/WriteFile cover the common cases, but os.OpenFile is the full control panel: a bitmask of flags plus a permission mode. The flags are OR-ed together — O_CREATE (make if missing), O_WRONLY/O_RDWR, O_APPEND (write at the end), O_TRUNC (empty first), O_EXCL (fail if it exists). Appending to a log file is the canonical example:

openfile.go — editable & runnable
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
path := filepath.Join(os.TempDir(), "app.log")
defer os.Remove(path)

// Open for appending, creating the file if it doesn't exist yet.
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
	fmt.Println("open error:", err)
	return
}
// *os.File is an io.Writer — fmt.Fprintf writes straight to it.
fmt.Fprintf(f, "first entry\n")
fmt.Fprintf(f, "second entry\n")
f.Close()

b, _ := os.ReadFile(path)
fmt.Print(string(b)) // both lines, in order
}

The permission mode is a Unix octal: 0644 is rw-r--r--, 0600 keeps a secret owner-only, 0755 is an executable or directory. (On Windows only the owner-write bit is honored.)

Directories and file info

os.Stat returns an fs.FileInfo — size, mode, modified time, and IsDir. A missing file yields an error you match with errors.Is(err, os.ErrNotExist) (the idiomatic “does it exist?”). os.MkdirAll makes a directory tree, and os.ReadDir lists a directory’s entries:

dirs.go — editable & runnable
package main

import (
"errors"
"fmt"
"os"
"path/filepath"
)

func main() {
root := filepath.Join(os.TempDir(), "demo")
defer os.RemoveAll(root) // remove the whole tree

// Make a nested directory and a couple of files.
os.MkdirAll(filepath.Join(root, "sub"), 0755)
os.WriteFile(filepath.Join(root, "a.txt"), []byte("hi"), 0644)
os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("there"), 0644)

// Stat: size + kind.
info, _ := os.Stat(filepath.Join(root, "a.txt"))
fmt.Printf("a.txt: %d bytes, dir=%v\n", info.Size(), info.IsDir())

// Existence check via errors.Is.
_, err := os.Stat(filepath.Join(root, "missing.txt"))
fmt.Println("missing.txt is absent:", errors.Is(err, os.ErrNotExist)) // true

// List a directory.
entries, _ := os.ReadDir(root)
for _, e := range entries {
	fmt.Printf("entry: %-6s dir=%v\n", e.Name(), e.IsDir())
}
}

To walk a whole tree recursively, use filepath.WalkDir(root, fn) — it visits every file and subdirectory, calling your function with the path and entry. It’s the efficient (Go 1.16+) replacement for the older filepath.Walk.

Portable paths with filepath

Never build paths by gluing strings with "/" — that breaks on Windows. The path/filepath package handles the OS-correct separator and gives you the pieces of a path:

p := filepath.Join("data", "logs", "app.log") // data/logs/app.log (or \ on Windows)
filepath.Base(p)   // "app.log"
filepath.Dir(p)    // "data/logs"
filepath.Ext(p)    // ".log"
filepath.Clean("a/../b/./c") // "b/c"
abs, _ := filepath.Abs(p)    // absolute path from the cwd
matches, _ := filepath.Glob("*.go") // shell-style matching

The os doorway

Beyond files, os is how a program reaches its environment and controls its own process:

  • os.Args — the command-line arguments (os.Args[0] is the program name); parse them with flag.
  • os.Getenv("KEY") / os.Setenv / os.LookupEnv — environment variables (LookupEnv returns an ok bool so you can tell unset from empty).
  • os.Stdin / os.Stdout / os.Stderr — the three standard streams, each an *os.File (so an io.Reader/io.Writer). That’s why fmt.Fprintln(os.Stderr, …) writes errors to the right place.
  • os.Exit(code) — exit immediately with a status code. It does not run deferred functions — reserve it for main, after cleanup.
fmt.Fprintln(os.Stderr, "warning: config not found")
home, ok := os.LookupEnv("HOME")
_ = ok

Reference

TaskCall
Read a small fileos.ReadFile(path)
Write a small fileos.WriteFile(path, b, 0644)
Open for streaming reados.Open(path)*os.File
Create/truncate for writeos.Create(path)
Append / full controlos.OpenFile(path, flags, perm)
File metadataos.Stat(path)FileInfo
Exists?errors.Is(err, os.ErrNotExist)
Make directory treeos.MkdirAll(dir, 0755)
List a directoryos.ReadDir(dir)
Walk a treefilepath.WalkDir(root, fn)
Delete file / treeos.Remove / os.RemoveAll
Build a pathfilepath.Join(...)

⚠️ Always close, check errors, and don't os.Exit past your defers

Three habits. (1) defer f.Close() the moment you open a file — a leaked descriptor eventually exhausts the OS limit. (2) Check the error on every file call, including Close() on a file you wrote (a deferred-then-buffered write can fail at close). (3) os.Exit and log.Fatal skip deferred functions, so cleanup never runs — call them only from main after your defers have done their work, never deep inside library code.

See also

  • fmt & io*os.File, os.Stdout, and friends are all io.Reader/io.Writer.
  • CLI Tools with flag — turn os.Args into typed options.
  • os/exec — run external programs and wire up their stdin/stdout.
  • errorserrors.Is(err, os.ErrNotExist) and sentinel errors.

Next: turn os.Args into real options — CLI Tools with flag.

Check your understanding

Score: 0 / 5

1. When should you reach for os.Open / bufio.Scanner instead of os.ReadFile?

os.ReadFile slurps the whole file into a []byte — simple and fine for small files. For large files, or when you process input a line at a time, os.Open gives you a streaming *os.File that bufio.Scanner reads incrementally.

2. Why use filepath.Join(dir, name) instead of dir + "/" + name?

Hard-coding "/" breaks on Windows (which uses backslashes) and leaves doubled or trailing separators. filepath.Join picks the right separator for the platform and normalizes the result.

3. What is *os.File, the type returned by os.Open and os.Create?

*os.File satisfies io.Reader and io.Writer (among others). That's why os.Stdout works with fmt.Fprintf and why any open file streams through io.Copy — it's just another Reader/Writer.

4. To append to a file (creating it if absent), which os.OpenFile flags do you combine?

OpenFile takes a bitmask of flags plus a permission mode. O_CREATE makes it if missing, O_WRONLY opens for writing, O_APPEND seeks to the end before each write. O_TRUNC would instead empty an existing file.

5. How do you check whether a file exists / get its size and mode?

os.Stat(path) returns an fs.FileInfo with Size(), Mode(), IsDir(), ModTime(). If the file is missing you get an error that errors.Is(err, os.ErrNotExist) matches — the idiomatic existence check.

Comments

Sign in with GitHub to join the discussion.