Files and Directories
Go's os package reads and writes files and manages directories, and path/filepath builds and takes apart file paths portably. This lesson covers reading and writing whole files, working line by line, appending, file information, creating and listing directories, walking a folder tree, and handling the errors that file operations produce.
Reading and writing a whole file
For files that fit comfortably in memory, os.WriteFile and os.ReadFile do everything in one call:
package main import ( "fmt" "os" ) func main() { data := []byte("first line\nsecond line\n") if err := os.WriteFile("notes.txt", data, 0o644); err != nil { fmt.Println("write failed:", err) return } content, err := os.ReadFile("notes.txt") if err != nil { fmt.Println("read failed:", err) return } fmt.Print(string(content)) fmt.Println(len(content), "bytes") }
first line second line 23 bytes
The 0o644 is the Unix permission for a new file: the owner can read and write, everyone else can only read. WriteFile replaces any existing content. Both functions work with []byte; convert with string() and []byte() as needed.
Reading line by line
For large files, or when you process one line at a time, open the file and scan it. os.Open opens for reading; always close the file when done:
package main import ( "bufio" "fmt" "os" "strings" ) func main() { os.WriteFile("todo.txt", []byte("buy milk\n# comment\ncall Ana\n\nfix bike\n"), 0o644) f, err := os.Open("todo.txt") if err != nil { fmt.Println(err) return } defer f.Close() sc := bufio.NewScanner(f) n := 0 for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } n++ fmt.Printf("%d. %s\n", n, line) } if err := sc.Err(); err != nil { fmt.Println("read error:", err) } }
1. buy milk 2. call Ana 3. fix bike
Writing step by step
os.Create creates (or truncates) a file for writing. Wrapping it in a bufio.Writer collects small writes into larger chunks, which is much faster; remember to Flush at the end. When writing, check the error from Close too — it can report that data didn't reach the disk:
package main import ( "bufio" "fmt" "os" ) func writeReport(path string, rows map[string]int, order []string) (err error) { f, err := os.Create(path) if err != nil { return err } defer func() { if cerr := f.Close(); err == nil { err = cerr } }() w := bufio.NewWriter(f) fmt.Fprintln(w, "product,sold") for _, k := range order { fmt.Fprintf(w, "%s,%d\n", k, rows[k]) } return w.Flush() } func main() { err := writeReport("sales.csv", map[string]int{"lamp": 12, "desk": 3}, []string{"desk", "lamp"}) fmt.Println("error:", err) data, _ := os.ReadFile("sales.csv") fmt.Print(string(data)) }
error: <nil> product,sold desk,3 lamp,12
Appending
os.OpenFile takes flags that control how the file is opened. To add to the end of a log, creating it if needed:
package main import ( "fmt" "os" ) func appendLine(path, line string) error { f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() _, err = f.WriteString(line + "\n") return err } func main() { appendLine("app.log", "started") appendLine("app.log", "request handled") data, _ := os.ReadFile("app.log") fmt.Print(string(data)) }
started request handled
File information and missing files
os.Stat returns size, modification time and whether the path is a directory. To check for "does not exist", test the error with errors.Is(err, fs.ErrNotExist):
package main import ( "errors" "fmt" "io/fs" "os" ) func main() { os.WriteFile("data.bin", make([]byte, 2048), 0o600) info, err := os.Stat("data.bin") if err == nil { fmt.Println(info.Name(), info.Size(), info.IsDir(), info.Mode()) } _, err = os.Stat("nope.txt") fmt.Println(errors.Is(err, fs.ErrNotExist)) fmt.Println(err) }
data.bin 2048 false -rw------- true stat nope.txt: no such file or directory
Avoid checking whether a file exists and then opening it — another program could change things in between. Just open it and handle the error.
Paths with filepath
Build paths with filepath.Join instead of gluing strings with /: it uses the right separator for the operating system (\ on Windows) and cleans up doubled slashes and ..:
package main import ( "fmt" "path/filepath" ) func main() { p := filepath.Join("data", "2026", "../2027", "report.final.csv") fmt.Println(p) fmt.Println(filepath.Dir(p), filepath.Base(p), filepath.Ext(p)) matched, _ := filepath.Match("*.csv", "report.csv") fmt.Println(matched) }
data/2027/report.final.csv data/2027 report.final.csv .csv true
Directories
package main import ( "fmt" "os" "path/filepath" ) func main() { os.MkdirAll(filepath.Join("site", "img", "icons"), 0o755) // creates every missing level os.WriteFile(filepath.Join("site", "index.html"), []byte("<h1>hi</h1>"), 0o644) os.WriteFile(filepath.Join("site", "style.css"), []byte("body{}"), 0o644) entries, err := os.ReadDir("site") // sorted by name if err != nil { fmt.Println(err) return } for _, e := range entries { kind := "file" if e.IsDir() { kind = "dir " } fmt.Println(kind, e.Name()) } fmt.Println(os.RemoveAll("site")) // delete the folder and everything in it }
dir img file index.html file style.css <nil>
Walking a directory tree
filepath.WalkDir visits every file and folder under a root, in lexical order, calling your function for each:
package main import ( "fmt" "io/fs" "os" "path/filepath" "strings" ) func main() { for _, p := range []string{"proj/main.go", "proj/go.mod", "proj/web/handler.go", "proj/web/static/app.js", "proj/.git/HEAD"} { os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(strings.Repeat("x", len(p))), 0o644) } total := 0 err := filepath.WalkDir("proj", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() && d.Name() == ".git" { return filepath.SkipDir // don't descend into .git } if filepath.Ext(path) == ".go" { info, _ := d.Info() total += int(info.Size()) fmt.Println(path) } return nil }) fmt.Println("total bytes of Go:", total, "error:", err) }
proj/main.go proj/web/handler.go total bytes of Go: 31 error: <nil>
Staying inside a folder: os.Root
When file names come from users or uploaded archives, a name like ../../etc/passwd could escape the folder you meant to use. os.OpenRoot returns an os.Root whose methods refuse any path that leads outside it:
package main import ( "fmt" "os" ) func main() { os.MkdirAll("uploads", 0o755) root, err := os.OpenRoot("uploads") if err != nil { fmt.Println(err) return } defer root.Close() fmt.Println(root.WriteFile("photo.jpg", []byte("..."), 0o644)) _, err = root.Open("../secret.txt") fmt.Println(err) }
<nil> openat ../secret.txt: path escapes from parent
Try it yourself
Count the lines in every .txt file in a folder and print a total.
docs/a.txt 3 docs/b.txt 1 total 4