Real programs read config files, write reports and process logs. In this lesson you’ll read and write whole files, go through a file line by line, append to a file, build file paths that work on every operating system, check whether a file exists, list a directory, and bake files right into your program with //go:embed.
Almost everything here lives in the os package. Every example creates its own temporary directory and removes it again, so you can run them anywhere without leaving junk behind.
Write and read a whole file
For small files, the easiest way is to write all the bytes at once with os.WriteFile and read them all back with os.ReadFile:
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "notes.txt")
err = os.WriteFile(path, []byte("Buy milk\nCall Ana\n"), 0644)
if err != nil {
log.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
fmt.Printf("read %d bytes:\n%s", len(data), data)
}Code language: Go (go)
read 18 bytes:
Buy milk
Call AnaCode language: plaintext (plaintext)
A few things to notice:
- Files hold bytes, so
WriteFiletakes a[]byteandReadFilereturns one. Convert with[]byte(s)andstring(data), as you saw in the strings lesson. os.MkdirTemp("", "files-demo")creates a fresh directory in your system’s temp folder (the empty string means “the default place”) and returns its name.defer os.RemoveAll(dir)deletes it, with everything in it, whenmainreturns.- If the file already exists,
WriteFilereplaces its contents. If it doesn’t exist, it creates it.
ReadFile loads the entire file into memory. That’s perfect for config files and small inputs. For a 10 GB log file, read it piece by piece instead (more on that below).
What does 0644 mean?
The last argument to WriteFile is the permission the file gets if it has to be created. It’s written in octal (the leading 0), and each of the last three digits is for one group of people: the owner (you), the file’s group, and everyone else. Each digit adds up read (4), write (2) and execute (1).
| Value | Meaning | Typical use |
|---|---|---|
0644 | you read and write, others only read | normal files |
0600 | only you read and write | files with secrets |
0755 | you do everything, others read and execute | directories, programs |
When in doubt, use 0644 for files and 0755 for directories. The operating system may remove some bits (on Linux and macOS your “umask” usually takes away write access for others), and Windows mostly ignores these numbers. Permissions are only applied when a file is created; overwriting an existing file keeps its old permissions.
Build paths with path/filepath
Don’t glue paths together with + "/" +. Windows uses backslashes, and it’s easy to end up with double slashes. The path/filepath package knows the rules for the system your program runs on:
package main
import (
"fmt"
"path/filepath"
)
func main() {
p := filepath.Join("reports", "2026", "summary.txt")
fmt.Println(p)
fmt.Println(filepath.Dir(p))
fmt.Println(filepath.Base(p))
fmt.Println(filepath.Ext(p))
fmt.Println(filepath.Join("reports/", "/2026//", "../old.txt"))
}Code language: Go (go)
reports/2026/summary.txt
reports/2026
summary.txt
.txt
reports/old.txtCode language: plaintext (plaintext)
That’s the output on Linux and macOS. On Windows you’d see backslashes. Join also cleans up the result: extra slashes go away and .. steps back up a directory.
There’s also a package called path (without file). It always uses forward slashes and is meant for URLs and other slash-separated names, not for files on disk.
Check whether a file exists
Go has no os.Exists. You try to use the file and look at the error. os.Stat returns information about a file, and if the file isn’t there its error matches fs.ErrNotExist from the io/fs package. You check that with errors.Is, just like in the errors lesson:
package main
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "config.json")
_, err = os.Stat(path)
fmt.Println("missing:", errors.Is(err, fs.ErrNotExist))
os.WriteFile(path, []byte("{}"), 0644)
info, err := os.Stat(path)
fmt.Println("missing:", errors.Is(err, fs.ErrNotExist))
fmt.Println(info.Name(), info.Size(), "bytes")
}Code language: Go (go)
missing: true
missing: false
config.json 2 bytesCode language: plaintext (plaintext)
Often you don’t need a separate check at all. If you want to read a file but it’s fine when it’s missing (an optional config file, say), call os.ReadFile and handle fs.ErrNotExist there. Checking first and opening later has a gap in between where another program could delete or create the file.
Open a file and close it with defer
When you want more control than “all at once”, open the file. os.Open opens a file for reading and gives you an *os.File. An open file uses a resource from the operating system, so you must close it when you’re done. That’s exactly what defer is for:
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// use f hereCode language: Go (go)
Put the defer right after the error check, never before it: if Open failed, f is nil and there’s nothing to close.
Read line by line with bufio.Scanner
An *os.File is an io.Reader, the standard interface for “something you can read bytes from” (see the interfaces lesson). A bufio.Scanner wraps any reader and hands you one line at a time, so even a huge file only needs a little memory:
package main
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "todo.txt")
os.WriteFile(path, []byte("water plants\n\nfix bike\nread chapter 3\n"), 0644)
f, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
n := 0
for scanner.Scan() {
n++
fmt.Printf("%d: %q\n", n, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}Code language: Go (go)
1: "water plants"
2: ""
3: "fix bike"
4: "read chapter 3"Code language: plaintext (plaintext)
scanner.Scan() moves to the next line and returns false at the end of the file or when something goes wrong. scanner.Text() returns the current line without the newline. After the loop, always check scanner.Err(): the loop ends the same way for “done” and for “read error”, and only Err tells them apart (it returns nil at a normal end of file).
By default a Scanner refuses lines longer than 64 KB and stops with the error bufio.Scanner: token too long. That’s plenty for normal text. For files with giant lines (minified JSON, for example), give it a bigger buffer with scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) before the first Scan.
Because a Scanner works on any io.Reader, the same loop reads from os.Stdin or from a string via strings.NewReader. You’ll use that in the command-line project.
Write with os.Create and bufio.Writer
os.Create opens a file for writing. It creates the file if needed (with permission 0666 before the umask, which usually ends up as 0644) and empties it if it already exists. You can write to the *os.File directly with fmt.Fprintln, but every write then goes straight to the operating system. When you write many small pieces, wrap the file in a bufio.Writer: it collects your writes in memory and passes them on in big chunks.
Here’s a function that writes a list of lines to a file, followed by a main that tries it out:
package main
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
)
func writeLines(path string, lines []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
w := bufio.NewWriter(f)
for i, line := range lines {
fmt.Fprintf(w, "%d. %s\n", i+1, line)
}
if err := w.Flush(); err != nil {
f.Close()
return err
}
return f.Close()
}
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "shopping.txt")
err = writeLines(path, []string{"apples", "bread", "coffee"})
if err != nil {
log.Fatal(err)
}
data, _ := os.ReadFile(path)
fmt.Print(string(data))
}Code language: Go (go)
1. apples
2. bread
3. coffeeCode language: plaintext (plaintext)
Two details matter here:
- Call
Flush. Until you do, the last bytes sit in the buffer. If you skip it, the file may end up empty or cut off, and you get no error. - Check the error from
Closewhen writing. The operating system may also hold on to data and only report a problem (a full disk, a network drive that went away) when you close the file. That’s whywriteLinesreturnsf.Close()instead of usingdefer f.Close(), which would throw that error away. For files you only read,defer f.Close()is fine: nothing can get lost.
The fmt.Fprintf calls inside the loop return errors too, and I ignore them on purpose. A bufio.Writer remembers the first error it hits, stops writing after that, and returns the error from Flush. So checking Flush covers all the writes before it.
Append to a file with os.OpenFile
os.Open and os.Create are shortcuts for os.OpenFile, which takes flags that say exactly how you want to open the file. You combine flags with |. To add lines to the end of a log file, creating it if needed:
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
func appendLine(path, line string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
if _, err := fmt.Fprintln(f, line); err != nil {
f.Close()
return err
}
return f.Close()
}
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "app.log")
for _, msg := range []string{"started", "user logged in", "stopped"} {
if err := appendLine(path, msg); err != nil {
log.Fatal(err)
}
}
data, _ := os.ReadFile(path)
fmt.Print(string(data))
}Code language: Go (go)
started
user logged in
stoppedCode language: plaintext (plaintext)
Each call opens the file, adds one line at the end, and closes it again. The flags you’ll use most:
| Flag | Meaning |
|---|---|
os.O_RDONLY | open for reading only |
os.O_WRONLY | open for writing only |
os.O_RDWR | open for reading and writing |
os.O_APPEND | every write goes to the end of the file |
os.O_CREATE | create the file if it doesn’t exist |
os.O_TRUNC | empty the file when opening it |
os.O_EXCL | with O_CREATE: fail if the file already exists |
Pick exactly one of the first three and add the others as needed. os.Create(name) is the same as os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666).
List a directory with os.ReadDir
os.ReadDir returns the entries of a directory, sorted by name. Each entry tells you its name and whether it’s a directory. os.Mkdir creates a directory (and os.MkdirAll creates any missing parents too):
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "files-demo")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bee"), 0644)
os.WriteFile(filepath.Join(dir, "a.md"), []byte("# A"), 0644)
os.MkdirAll(filepath.Join(dir, "images", "2026"), 0755)
entries, err := os.ReadDir(dir)
if err != nil {
log.Fatal(err)
}
for _, e := range entries {
if e.IsDir() {
fmt.Println(e.Name() + "/")
continue
}
fmt.Println(e.Name(), filepath.Ext(e.Name()))
}
}Code language: Go (go)
a.md .md
b.txt .txt
images/Code language: plaintext (plaintext)
ReadDir only looks one level deep, so images/2026 doesn’t show up. To go through a whole tree, use filepath.WalkDir, which calls a function for every file and directory below a starting point.
I use os.MkdirTemp for a temporary directory. When you only need one temporary file, os.CreateTemp(dir, "upload-*.txt") creates and opens one with a random name in place of the *. As with directories, removing it afterwards is your job (os.Remove(f.Name())).
Embed files in your program with //go:embed
Sometimes a file belongs to your program rather than to the user: a default config, an HTML template, a help text. Instead of shipping it next to the executable and hoping it’s found, you can compile it right into the binary with the embed package. Say your project looks like this:
greeter/
├── go.mod
├── main.go
└── greeting.txtCode language: plaintext (plaintext)
and greeting.txt contains Hello from an embedded file!. In main.go, put a //go:embed comment directly above a package-level variable:
import _ "embed"
//go:embed greeting.txt
var greeting stringCode language: Go (go)
Now greeting holds the file’s contents, and fmt.Print(greeting) in main prints it:
go run .Code language: Bash (bash)
Hello from an embedded file!Code language: plaintext (plaintext)
The rules: no space between // and go:embed, the variable must be at package level (not inside a function), the path is relative to the source file’s directory, and you need to import embed (with _ if you don’t use anything from it by name). The variable can be a string, a []byte, or an embed.FS for a whole set of files:
import "embed"
//go:embed templates/*.txt
var templates embed.FS
func loadTemplate(name string) (string, error) {
data, err := templates.ReadFile("templates/" + name)
return string(data), err
}Code language: Go (go)
The files are read at build time. Change greeting.txt and you have to build again to see the change, but the program also no longer needs the file at run time, so you can copy the single executable anywhere.
Practice
1. Count non-empty lines. Write a function countLines(path string) (int, error) that opens a file, reads it with a bufio.Scanner, and returns how many lines are not empty (ignore lines with only spaces, too: strings.TrimSpace helps). Test it in main on a temporary file containing "one\n\n \ntwo\nthree\n". It should report 3.
Show a solution
package main
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
func countLines(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
n := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if strings.TrimSpace(scanner.Text()) != "" {
n++
}
}
return n, scanner.Err()
}
func main() {
dir, err := os.MkdirTemp("", "practice")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "input.txt")
if err := os.WriteFile(path, []byte("one\n\n \ntwo\nthree\n"), 0644); err != nil {
log.Fatal(err)
}
n, err := countLines(path)
if err != nil {
log.Fatal(err)
}
fmt.Println("non-empty lines:", n)
_, err = countLines(filepath.Join(dir, "nope.txt"))
fmt.Println("error for a missing file:", err != nil)
}Code language: Go (go)
non-empty lines: 3
error for a missing file: trueCode language: plaintext (plaintext)2. Find the text files. Write textFiles(dir string) ([]string, error) that returns the names of all files in dir ending in .txt, skipping subdirectories. Create a temporary directory with notes.txt, photo.jpg, todo.txt and a subdirectory old.txt (yes, a directory with that name) and print the result.
Show a solution
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
func textFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".txt" {
names = append(names, e.Name())
}
}
return names, nil
}
func main() {
dir, err := os.MkdirTemp("", "practice")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
for _, name := range []string{"notes.txt", "photo.jpg", "todo.txt"} {
os.WriteFile(filepath.Join(dir, name), nil, 0644)
}
os.Mkdir(filepath.Join(dir, "old.txt"), 0755)
names, err := textFiles(dir)
if err != nil {
log.Fatal(err)
}
fmt.Println(names)
}Code language: Go (go)
[notes.txt todo.txt]Code language: plaintext (plaintext)Key takeaways
os.ReadFileandos.WriteFilehandle small files in one call; use0644for normal files.- Open files with
os.Open,os.Createoros.OpenFile, and close them. For reading,defer f.Close(); for writing, check the error fromClose. bufio.Scannerreads line by line (checkErrafter the loop);bufio.Writerspeeds up many small writes but needsFlush.- Build paths with
filepath.Join, and detect missing files witherrors.Is(err, fs.ErrNotExist). os.ReadDirlists a directory sorted by name;//go:embedbakes files into the binary at build time.