23.4 bufio.Reader and bufio.Writer: Buffered I/O
Right, let’s talk about buffered I/O. You’re probably thinking, “Why do I need a special wrapper for my readers and writers? Isn’t the io.Reader and io.Writer interface enough?” In a perfect world, maybe. But in our world, where syscalls are expensive and reading one byte at a time from a disk is like buying a single potato chip from a vending machine—technically possible, but a spectacularly inefficient way to live your life.
Enter bufio. This package is your best friend when you care about performance. It places a clever, in-memory buffer (a byte slice, really) between your code and the underlying io.Reader or io.Writer. Instead of making a system call for every single Read or Write, it handles data in chunks. It waits until the buffer is full (or you explicitly tell it to flush) before it bothers the kernel. The result? Far fewer, much larger operations. It’s the difference between making one trip to the grocery store with a list versus going back every time you remember you need an onion.
The bufio.Reader: Your Data’s Waiting Room
Think of a bufio.Reader as a well-stocked pantry. You ask it for a snack (Read a byte), and it gets it from the pantry (its buffer) instantly. Only when the pantry is empty does it have to go to the store (the underlying reader) to restock in bulk.
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
// Let's use a simple string as our source, but this could be a file, network connection, etc.
data := "Hello, World!\nThis is a second line."
reader := bufio.NewReader(strings.NewReader(data))
// Peek at the next 5 bytes without actually consuming them.
// This is like looking at the next chip in the bag without taking it out.
peeked, err := reader.Peek(5)
if err != nil {
panic(err)
}
fmt.Printf("Peeked: %s\n", peeked) // Output: Peeked: Hello
// Now read 5 bytes for real. This will consume the bytes we just peeked at.
readBuf := make([]byte, 5)
n, err := reader.Read(readBuf)
if err != nil {
panic(err)
}
fmt.Printf("Read %d bytes: %s\n", n, readBuf) // Output: Read 5 bytes: Hello
// Read until the first newline. The '\n' is included in the returned string.
line, err := reader.ReadString('\n')
if err != nil {
panic(err)
}
fmt.Printf("Read a line: %q\n", line) // Output: Read a line: ", World!\n"
}
The Peek method is uniquely useful. It lets you look ahead in the stream without advancing your current position. Fantastic for parsing, where you might need to check what’s coming next before you decide how to handle it. Just remember, the slice it returns is a direct view into the reader’s internal buffer. It’s a loaner car. Don’t hang onto it for too long and absolutely don’t modify it, because the next read operation will likely overwrite that memory.
The bufio.Writer: The Procrastinator’s Dream
While the Reader is about efficient intake, the bufio.Writer is all about efficient output. It’s the master of procrastination. You hand it data with Write, and it just chucks it into its internal buffer, smiling and nodding. It hasn’t actually done anything yet. The work only happens when the buffer is full, or when you finally call Flush(). This is brilliant for reducing many small writes into a few large ones.
package main
import (
"bufio"
"os"
)
func main() {
file, err := os.Create("output.txt")
if err != nil {
panic(err)
}
defer file.Close()
writer := bufio.NewWriter(file)
// These writes are just piling up in memory. Fast!
writer.WriteString("First line\n")
writer.WriteString("Second, much longer line of text that might fill the buffer...\n")
writer.WriteString("Third line\n")
// This is the crucial part. Until you flush, the file might be empty.
// Forgetting to flush is the #1 rookie mistake with bufio.Writer.
err = writer.Flush()
if err != nil {
panic(err)
}
// Now the data is actually written to disk.
}
The default buffer size is 4096 bytes, which is a sensible default. But if you’re dealing with massive files or specific performance requirements, you can specify your own size with bufio.NewWriterSize. The real gotcha here, the one that will bite you at 3 AM, is forgetting to Flush(). Your code will run, it will seem successful, but your file will be empty or missing its last few lines because that data is still sitting comfortably in the buffer, waiting for a ride to the disk that never comes. Always defer writer.Flush() right after you create the writer. It’s a cheap insurance policy.
The Scanners and the Gotchas
For reading lines, bufio.Scanner is often your best bet. It’s simpler than juggling ReadString yourself.
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() { // Scan() advances to the next token (usually line)
fmt.Println(scanner.Text()) // Get the text of the current line
}
if err := scanner.Err(); err != nil {
// Handle error. Important: Scanner can break on very long lines
// because it uses a default max token size.
panic(err)
}
Ah, and there’s the catch I just mentioned. The default buffer for a Scanner has a maximum token size of 64KB. If you have a line longer than that, Scan() will return false and break your loop with an error hidden in scanner.Err(). It’s a classic “it works on my test file” problem. If you’re dealing with input that has potentially massive lines, you must use bufio.Scanner’s less-known sibling:
scanner := bufio.NewScanner(reader)
buf := make([]byte, 0, 64*1024) // Initial buffer of 64KB
scanner.Buffer(buf, 10*1024*1024) // But allow it to grow up to 10MB
In short, bufio isn’t magic, but it’s the next best thing: a well-understood, predictable optimization. Use a Reader when you need peek-ahead or manual control, a Writer to batch expensive output operations, and a Scanner for simple line-by-line reading (but always check its error and consider your max line length!). Just don’t forget to flush. Seriously. I’m not kidding about that part.