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:

GO
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")
}
Output
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:

GO
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)
	}
}
Output
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:

GO
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))
}
Output
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:

GO
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))
}
Output
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):

GO
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)
}
Output
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 ..:

GO
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)
}
Output
data/2027/report.final.csv
data/2027 report.final.csv .csv
true

Directories

GO
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
}
Output
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:

GO
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)
}
Output
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:

GO
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)
}
Output
<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.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
docs/a.txt 3
docs/b.txt 1
total 4
Next: readers, writers and streaming data.