🗄️ 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):
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:
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:
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:
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 withflag.os.Getenv("KEY")/os.Setenv/os.LookupEnv— environment variables (LookupEnvreturns anokbool so you can tell unset from empty).os.Stdin/os.Stdout/os.Stderr— the three standard streams, each an*os.File(so anio.Reader/io.Writer). That’s whyfmt.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 formain, after cleanup.
fmt.Fprintln(os.Stderr, "warning: config not found")
home, ok := os.LookupEnv("HOME")
_ = ok
Reference
| Task | Call |
|---|---|
| Read a small file | os.ReadFile(path) |
| Write a small file | os.WriteFile(path, b, 0644) |
| Open for streaming read | os.Open(path) → *os.File |
| Create/truncate for write | os.Create(path) |
| Append / full control | os.OpenFile(path, flags, perm) |
| File metadata | os.Stat(path) → FileInfo |
| Exists? | errors.Is(err, os.ErrNotExist) |
| Make directory tree | os.MkdirAll(dir, 0755) |
| List a directory | os.ReadDir(dir) |
| Walk a tree | filepath.WalkDir(root, fn) |
| Delete file / tree | os.Remove / os.RemoveAll |
| Build a path | filepath.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 allio.Reader/io.Writer. - CLI Tools with flag — turn
os.Argsinto typed options. - os/exec — run external programs and wire up their stdin/stdout.
- errors —
errors.Is(err, os.ErrNotExist)and sentinel errors.
Next: turn os.Args into real options — CLI Tools with flag.
Related topics
Formatting and streaming — the fmt verbs you'll actually use, width/precision flags, the Stringer/Formatter hooks, and the tiny io.Reader/io.Writer interfaces (plus io.Copy, MultiWriter, TeeReader) that everything plugs into.
systemCLI Tools with flagBuild command-line tools with the flag package — typed options with defaults, binding with Var, positional args, subcommands via FlagSet, custom flag.Value types, and env-var fallbacks.
systemos/execRunning external programs from Go — exec.Command, Run vs Output vs Start, capturing stdout/stderr, streaming and pipes, context timeouts, environment and working directory, and why there's no shell (and why that's a security win).
Check your understanding
Score: 0 / 51. 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.