Lesson 13 of 30 intermediate 5 min read

Before this:Interfaces & composition

Working with files & I/O

Key takeaways Open files with os.Open (read) and os.Create (write), and always defer f.Close(). The real power is the io.Reader and io.Writer interfaces — one-method contracts that files, network connections, and buffers all satisfy, so the same code works on any of them. bufio adds buffering and convenient line scanning.

Reading and writing data is where the interfaces lesson pays off: Go models every stream through two tiny interfaces. This lesson shows the everyday patterns.

Opening and closing a file

os.Open returns a *os.File (which is an io.Reader) and an error. Pair every open with a deferred close:

f, err := os.Open("capture.cfile")
if err != nil {
    return fmt.Errorf("open capture: %w", err)
}
defer f.Close()   // runs when the function returns, even on error

defer guarantees cleanup runs no matter which path the function takes — the reason you see it on the line right after every successful open.

The two interfaces that tie it together

Almost all of Go’s I/O is built on two one-method interfaces:

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }

Because a file, a network socket, a byte buffer, and a gzip stream all satisfy these, a function written against io.Reader works with every one of them. io.Copy is the classic example — it pipes any reader into any writer:

n, err := io.Copy(dst, src)   // dst is any Writer, src is any Reader
src (io.Reader) io.Copy dst (io.Writer)
Code written against io.Reader/io.Writer works with any source and any sink — files, sockets, or in-memory buffers.

Buffering and scanning lines

Reading a file line by line is a bufio.Scanner:

f, _ := os.Open("channels.txt")
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() {
    line := sc.Text()   // one line, no trailing newline
    addChannel(line)
}
if err := sc.Err(); err != nil {
    return err
}

For writing, wrap the file in a bufio.Writer so many small writes batch into a few system calls — then Flush before the file closes:

w := bufio.NewWriter(f)
defer w.Flush()
fmt.Fprintf(w, "%s,%.0f\n", ch.Name, ch.FreqHz)

Whole-file shortcuts

When a file is small enough to hold in memory, os.ReadFile and os.WriteFile collapse open/read/close into one call:

data, err := os.ReadFile("scanlist.json")   // []byte of the whole file
err = os.WriteFile("out.json", data, 0o644)

This is how GopherTrunk loads a JSON scan list at startup — read the bytes, then json.Unmarshal them into the config struct from the previous lesson.

Quick check: why put defer f.Close() right after opening a file?

Recap

  • Open with os.Open/os.Create and defer f.Close() immediately.
  • io.Reader and io.Writer are one-method interfaces every stream satisfies, so I/O code composes.
  • bufio buffers reads/writes and scans lines; remember to Flush a buffered writer.
  • os.ReadFile/os.WriteFile handle a whole small file in one call.

Next up: Go’s headline feature — launching concurrent work with goroutines.

Frequently asked questions

Why do I always see defer file.Close() in Go?

Because an open file holds an OS resource that must be released. defer schedules Close to run when the function returns — whether it returns normally or early on an error — so the file is always closed and you never leak a handle. It’s the standard Go cleanup pattern.

What's the point of bufio?

Raw file reads and writes each hit the operating system, which is slow when done a byte or line at a time. bufio wraps a reader or writer with an in-memory buffer, so many small operations become a few big ones. bufio.Scanner also makes line-by-line reading trivial.