Reading and writing files in Go

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 WriteFile takes a []byte and ReadFile returns one. Convert with []byte(s) and string(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, when main returns.
  • If the file already exists, WriteFile replaces 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).

ValueMeaningTypical use
0644you read and write, others only readnormal files
0600only you read and writefiles with secrets
0755you do everything, others read and executedirectories, 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.

How bufio.Writer, Flush and Close work together Your code writes small pieces to a bufio.Writer, which keeps them in a memory buffer. Flush sends the buffered bytes to the file. Close releases the file. Data still in the buffer when the program ends is lost. your code Fprintln(w, …) Fprintln(w, …) Fprintln(w, …) buffered writer bufio.Writer buffer in memory Flush file on disk *os.File Close when done, and check its error Forget Flush and whatever is still in the buffer never reaches the file.
Writes go into the buffer first. Only Flush moves them to the file.

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:

  1. 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.
  2. Check the error from Close when 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 why writeLines returns f.Close() instead of using defer 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:

FlagMeaning
os.O_RDONLYopen for reading only
os.O_WRONLYopen for writing only
os.O_RDWRopen for reading and writing
os.O_APPENDevery write goes to the end of the file
os.O_CREATEcreate the file if it doesn’t exist
os.O_TRUNCempty the file when opening it
os.O_EXCLwith 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.ReadFile and os.WriteFile handle small files in one call; use 0644 for normal files.
  • Open files with os.Open, os.Create or os.OpenFile, and close them. For reading, defer f.Close(); for writing, check the error from Close.
  • bufio.Scanner reads line by line (check Err after the loop); bufio.Writer speeds up many small writes but needs Flush.
  • Build paths with filepath.Join, and detect missing files with errors.Is(err, fs.ErrNotExist).
  • os.ReadDir lists a directory sorted by name; //go:embed bakes files into the binary at build time.