Read and Write Files in Go the Simple Way With Examples
Use os.ReadFile and WriteFile for small files, bufio for large ones, OpenFile flags to append, errors.Is for missing files and a rename for atomic writes.

Reading and writing files is one of the first things any program does, and Go gives you a layered set of tools for it: one-call functions for the common case, os.File with io and bufio for streaming, and a few operations for the cases where partial failure matters. This how-to goes through each in the order you will need them, with a program that runs end to end and prints its results. The checks around errors are where most real bugs in file code hide, so they get their own attention. It belongs to the Go basics path, follows reading user input, and uses the error conventions from functions with multiple returns.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64); permission output assumes the default 022 umask
One program, every operation
The program works in a fresh temporary directory, using relative file names, so the messages it prints are the same on every machine:
package main
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
)
func must(err error) {
if err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
}
func main() {
dir, err := os.MkdirTemp("", "files")
must(err)
defer os.RemoveAll(dir)
must(os.Chdir(dir))
must(os.WriteFile("notes.txt", []byte("first\nsecond\n"), 0o644))
data, err := os.ReadFile("notes.txt")
must(err)
fmt.Printf("read %d bytes: %q\n", len(data), data)
f, err := os.OpenFile("notes.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
must(err)
_, err = f.WriteString("third\n")
must(err)
must(f.Close())
in, err := os.Open("notes.txt")
must(err)
defer in.Close()
scanner := bufio.NewScanner(in)
for n := 1; scanner.Scan(); n++ {
fmt.Printf("%d: %s\n", n, scanner.Text())
}
must(scanner.Err())
_, err = os.ReadFile("missing.txt")
fmt.Println(err, errors.Is(err, fs.ErrNotExist))
info, err := os.Stat("notes.txt")
must(err)
fmt.Println(info.Name(), info.Size(), info.Mode(), info.IsDir())
must(os.MkdirAll("out/deep", 0o755))
src, err := os.Open("notes.txt")
must(err)
defer src.Close()
dst, err := os.Create("out/copy.txt")
must(err)
n, err := io.Copy(dst, src)
must(err)
must(dst.Close())
fmt.Println("copied", n, "bytes")
entries, err := os.ReadDir(".")
must(err)
for _, e := range entries {
fmt.Println(e.Name(), e.IsDir())
}
fmt.Println(strings.Repeat("-", 5))
}read 13 bytes: "first\nsecond\n"
1: first
2: second
3: third
open missing.txt: no such file or directory true
notes.txt 19 -rw-r--r-- false
copied 19 bytes
notes.txt false
out true
-----Walk through the results. WriteFile created notes.txt with 13 bytes, and ReadFile returned them as a []byte, which %q prints with the newlines visible. The append opened the existing file with three flags together: O_APPEND positions every write at the end, O_CREATE makes the file if it is missing and O_WRONLY says you will only write. The scanner then printed all three lines with numbers. The missing file produced open missing.txt: no such file or directory, and errors.Is(err, fs.ErrNotExist) was true. The Stat call reports the name, the size, the mode, -rw-r--r--, and that it is not a directory. io.Copy moved the bytes into a new file and returned the count. ReadDir lists a directory sorted by file name.
Small files: ReadFile and WriteFile
os.ReadFile opens, reads everything, closes and returns the bytes. It is the right call for configuration, templates and anything you would be happy to hold in memory, which is nearly everything below a few megabytes. os.WriteFile creates the file or truncates an existing one, writes the bytes and closes it. The third argument is the permission mode for a file that is created. 0o644 means the owner can read and write and everyone else can read. The mode is masked by the process's umask, so on a typical system with umask 022 you get what you asked for, and on a locked-down one you may get less. The mode is ignored when the file already exists, so WriteFile does not change permissions of an existing file.
Write modes with the 0o prefix, which is the modern octal literal. A bare 644 is the decimal number 644, which is a different and wrong mode, and the compiler will not warn you.
Large files: stream them
A log file of a gigabyte does not belong in ReadFile. Open it and read through a buffered reader, as the scanner loop above did. The same limit applies as for standard input: the scanner's default maximum line is 64 KB, and reading user input shows how to raise it. For byte oriented work, io.Copy(dst, src) moves data in chunks without holding it all, and io.ReadAll is the one to avoid on files of unknown size. A *os.File is also an io.Reader, so everything that accepts a reader, json.NewDecoder, gzip.NewReader, csv.NewReader, works on it directly, and the scanner-versus-reader decision is the same decision. The pattern for every open file is the same two lines: check the error from os.Open, then defer f.Close().
The close error on a file you wrote
For a file you only read, ignoring the error from Close is harmless. For a file you wrote, Close is where buffered data is flushed to the operating system, and on some file systems, network drives especially, a write error shows up there and nowhere earlier. The program above checked f.Close() and dst.Close() explicitly, and used defer only for the read-only handles. If you write with defer f.Close() and ignore its result, a failure to save becomes a silent data loss. Errors from deferred Close calls shows the pattern that returns the close error through a named result, and the short version is to treat Close on a writer as part of the write.
Missing files, and what to compare
Check for absence with errors.Is(err, fs.ErrNotExist). Do not compare err.Error() to a string, and do not use os.IsNotExist(err), which is the older form that does not see through wrapped errors. The same function family covers permission problems, fs.ErrPermission, and an existing file, fs.ErrExist. The error type for file operations is *fs.PathError, which holds the operation, the path and the underlying system error, and you can reach it with errors.As to read the path. The messages for these are in no such file or directory and permission denied.
Replacing a file without a window of corruption
os.WriteFile truncates the target first and writes second, so a crash in between leaves an empty or partial file. For configuration or any file that other programs may read at any moment, the safe method is to write the new content to a temporary file in the same directory and rename it over the target. A rename within one file system is atomic on Unix, so readers see either the old file or the new one:
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)The temporary file has to be in the same directory, because a rename across file systems is a copy and not atomic. If the data must survive a power loss, call tmp.Sync() before the close. On Windows, renaming over an existing file has restrictions that the Go runtime papers over on recent versions, and it is worth testing there if you ship to it.
Paths, directories and the fs package
Build paths with filepath.Join, which uses the right separator for the platform and cleans the result, and never with string concatenation. os.MkdirAll creates a directory and all its parents and does nothing if it exists. filepath.WalkDir or fs.WalkDir walks a tree, and the io/fs interfaces, such as fs.FS, let functions work on real directories, embedded files and in-memory file systems alike, which is how embed and testing/fstest fit. A function that takes an fs.FS is easy to test with fstest.MapFS and no temporary directories. Prefer that shape for code that only reads. The authoritative references are the os package and the io/fs package.


