File Handling
Learn how to read and write files in Go using the os package, os.ReadFile and os.WriteFile, and how to check file errors properly.
Introduction
Reading configuration, writing logs, processing uploaded data — nearly every real program eventually touches the filesystem. Go's os package gives you both simple, whole-file convenience functions and lower-level file handles for streaming, all wrapped in Go's familiar explicit-error style.
- How to read an entire file at once with os.ReadFile.
- How to write data to a file with os.WriteFile.
- How to work with an os.File handle for more control.
- How to check and handle file errors properly, including missing files.
Reading a Whole File
For small to medium files, os.ReadFile is the simplest option: it opens the file, reads the entire contents into a []byte, and closes the file for you in one call.
package main
import ( "fmt" "os")
func main() { data, err := os.ReadFile("notes.txt") if err != nil { fmt.Println("failed to read file:", err) return }
fmt.Println(string(data))}Click Run to see what this code prints.
Writing a Whole File
os.WriteFile is the write-side counterpart: it creates the file if needed (or truncates it if it exists), writes the given bytes, and closes it, all in one call. The permission argument (0644 here) only matters on the first creation.
package main
import ( "fmt" "os")
func main() { content := []byte("Go makes file I/O straightforward.\n")
err := os.WriteFile("output.txt", content, 0644) if err != nil { fmt.Println("failed to write file:", err) return }
fmt.Println("file written successfully")}Click Run to see what this code prints.
Working with os.File Directly
For large files, streaming, or appending, use os.Open, os.Create, or os.OpenFile to get an *os.File handle, and always defer its Close(). Wrapping it in a bufio.Scanner is a common way to process a file line by line without loading it all into memory.
package main
import ( "bufio" "fmt" "os")
func main() { file, err := os.Open("notes.txt") if err != nil { fmt.Println("failed to open file:", err) return } defer file.Close()
scanner := bufio.NewScanner(file) lineNum := 1 for scanner.Scan() { fmt.Printf("%d: %s\n", lineNum, scanner.Text()) lineNum++ }
if err := scanner.Err(); err != nil { fmt.Println("error reading file:", err) }}Click Run to see what this code prints.
To append to an existing file rather than overwrite it, use os.OpenFile with the O_APPEND and O_WRONLY flags: os.OpenFile("log.txt", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644).
Checking File Errors Properly
File operations fail for many reasons: a missing file, a permissions problem, a full disk. The os package provides os.IsNotExist (and, more modernly, errors.Is(err, os.ErrNotExist)) to check specifically for a "file not found" condition rather than treating every error the same way.
package main
import ( "errors" "fmt" "os")
func main() { _, err := os.ReadFile("does-not-exist.txt") if err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Println("that file does not exist, using defaults instead") } else { fmt.Println("unexpected error reading file:", err) } return }}Click Run to see what this code prints.
Common Mistakes
- Forgetting to defer file.Close() after os.Open or os.Create, leaking file descriptors.
- Using os.ReadFile on very large files, loading gigabytes into memory at once.
- Treating every file error the same instead of distinguishing "not found" from other failures.
- Ignoring the error returned by Close() when writes must be guaranteed to reach disk.
- Assuming a relative file path always resolves to where you expect — it depends on the working directory.
Best Practices
- Use os.ReadFile / os.WriteFile for small files; stream with os.File and bufio for large ones.
- Always defer Close() immediately after a successful Open, Create, or OpenFile call.
- Check errors with errors.Is(err, os.ErrNotExist) rather than comparing strings.
- Use filepath.Join to build file paths portably across operating systems.
- Set sensible permission bits (like 0644 for regular files) when creating new files.
Frequently Asked Questions
os.Open opens an existing file for reading (read-only by default). os.Create creates a new file for writing, truncating it if it already exists.
Use os.OpenFile with the os.O_APPEND and os.O_WRONLY flags combined with os.O_CREATE, instead of os.WriteFile, which always truncates.
Not always. os.ReadFile is fine for small files. Use bufio.Scanner or bufio.Reader when processing large files line by line without loading everything into memory at once.
Key Takeaways
- os.ReadFile and os.WriteFile handle small whole-file reads and writes in one call each.
- os.Open, os.Create, and os.OpenFile return an *os.File for more control, and always need a deferred Close().
- bufio.Scanner is the idiomatic way to process a file line by line.
- errors.Is(err, os.ErrNotExist) is the correct way to check for a missing file.
- File I/O follows the same explicit if err != nil pattern as the rest of Go.
Summary
Go's os package covers everything from one-line whole-file reads and writes to fine-grained streaming with os.File, all while keeping error handling explicit and predictable. Next, you will build on this by learning how to encode and decode JSON — a natural companion to reading and writing structured data.