22 — I/O, Files & the io Package
Go's I/O is built around two small interfaces — io.Reader and io.Writer — that compose into pipelines for files, networks, compression, and encoding.
The Two Core Interfaces
go
// ┌──────────────────────────────────────────────────────────────────────┐
// │ io.Reader │
// │ Read(p []byte) (n int, err error) │
// │ Fills p up to len(p) bytes. Returns n=bytes read, err=io.EOF at end│
// │ ⚠️ May return n < len(p) and err=nil — that's OK, call Read again │
// │ ⚠️ Never assume Read fills the buffer — always use the returned n │
// └──────────────────────────────────────────────────────────────────────┘
// ┌──────────────────────────────────────────────────────────────────────┐
// │ io.Writer │
// │ Write(p []byte) (n int, err error) │
// │ Writes p. Returns n=bytes written, err if not all written. │
// │ ⚠️ If n < len(p) and err=nil, it's a short write — caller retries │
// └──────────────────────────────────────────────────────────────────────┘
// Implementations: *os.File, *bytes.Buffer, *strings.Builder, net.Conn,
// *gzip.Reader/Writer, *http.Response.Body, *json.Encoder, etc.
// ─── Custom Reader (a rate-limited reader) ───
type rateLimitReader struct {
r io.Reader
limit int // bytes per second
tokens int
ticker *time.Ticker
}
func (r *rateLimitReader) Read(p []byte) (int, error) {
// Wait for tokens, then read up to the available amount
<-r.ticker.C // one token per tick
max := min(len(p), r.limit)
return r.r.Read(p[:max])
}
io.Copy — The Zero-Copy Transfer
go
// io.Copy reads from src and writes to dst until EOF, using a 32KB buffer.
// It's the standard way to transfer data without manual buffering.
func copyFile(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, fmt.Errorf("open src: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return 0, fmt.Errorf("create dst: %w", err)
}
defer out.Close()
n, err := io.Copy(out, in) // returns bytes copied
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
// ⚠️ defer out.Close() error is lost — call Sync explicitly:
if err := out.Sync(); err != nil { // flush to disk
return 0, fmt.Errorf("sync: %w", err)
}
return n, nil
}
// ─── io.CopyN — copy exactly N bytes ───
n, err := io.CopyN(dst, src, 1024) // copies exactly 1024 bytes (or fewer if EOF)
// ─── io.LimitReader — wrap to limit reads ───
limited := io.LimitReader(src, 1024) // reads at most 1024 bytes from src
io.Copy(dst, limited) // copies at most 1024 bytes
bufio — Buffered Scanning
go
// bufio.Scanner — line-by-line reading (most common pattern):
func readLines(path string) error {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() { // reads one line at a time
line := scanner.Text()
fmt.Println(line)
}
// ⚠️ Always check scanner.Err() after the loop:
return scanner.Err() // returns io.EOF normally, or an error
}
// ─── Scanner buffer size — the 64KB limit trap ───
func readLongLines(path string) error {
f, _ := os.Open(path)
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB max line
// Default max line is 64KB — lines longer than that cause scanner.Err()
// to return bufio.ErrTooLong. Increase the buffer for long lines.
for scanner.Scan() {
// process line
}
return scanner.Err()
}
// ─── bufio.Reader — lower-level, more control ───
func readWithReader(path string) error {
f, _ := os.Open(path)
defer f.Close()
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n') // reads up to and including '\n'
if err != nil {
if err == io.EOF && line != "" {
process(line) // last line without newline
}
break
}
process(line)
}
return nil
}
File Operations
go
func fileOps() {
// Read all (Go 1.16+ — replaces ioutil.ReadFile):
data, err := os.ReadFile("config.yaml")
if err != nil { /* file doesn't exist, permission denied, etc. */ }
// Write all (Go 1.16+):
err = os.WriteFile("output.txt", []byte("hello"), 0644)
// 0644 = user: rw, group: r, other: r
// Open for reading:
f, _ := os.Open("file.txt")
defer f.Close()
// Create/open for writing (truncates if exists):
f, _ = os.Create("new.txt")
defer f.Close()
// Open with explicit flags:
f, _ = os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
defer f.Close()
f.WriteString("appended line\n")
// ─── File flags ───
// os.O_RDONLY read only
// os.O_WRONLY write only
// os.O_RDWR read/write
// os.O_CREATE create if not exists
// os.O_TRUNC truncate to 0 on open
// os.O_APPEND append to end (no overwrite)
// os.O_EXCL fail if file exists (with O_CREATE)
// os.O_SYNC synchronous I/O (no buffering, for durability)
}
// ─── The defer-close error trap ───
func writeFile(path string, data []byte) error {
f, err := os.Create(path)
if err != nil { return err }
defer f.Close() // ⚠️ Close() error is DISCARDED by defer
if _, err := f.Write(data); err != nil {
return err // f.Close() runs in defer, but error is lost
}
// ✅ For write durability, call Sync and Close explicitly:
if err := f.Sync(); err != nil { // flush kernel buffers to disk
f.Close()
return fmt.Errorf("sync: %w", err)
}
if err := f.Close(); err != nil { // close can fail (flush error)
return fmt.Errorf("close: %w", err)
}
return nil
}
Streaming Pipeline — Composing Readers and Writers
go
// ─── gzip-compressed HTTP response ───
func decompressResponse(resp *http.Response) ([]byte, error) {
// resp.Body is a Reader → gzip.Reader → io.ReadAll → bytes
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
return io.ReadAll(gz) // decompressed bytes
}
// ─── gzip-compressed file writer ───
func writeGzipped(path string, data []byte) error {
f, err := os.Create(path)
if err != nil { return err }
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close() // ⚠️ must Close to flush compressed data
_, err = gz.Write(data)
return err
}
// ─── Base64-encoded gzip stream ───
// Read: file → base64 decoder → gzip decoder → JSON decoder → struct
func decodeNested(r io.Reader) (*Config, error) {
b64 := base64.NewDecoder(base64.StdEncoding, r)
gz, err := gzip.NewReader(b64)
if err != nil { return nil, err }
defer gz.Close()
var cfg Config
if err := json.NewDecoder(gz).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
io.Pipe — In-Memory Stream Between Goroutines
go
// io.Pipe creates a synchronous pipe: a Reader and Writer pair where
// writes block until reads consume (and vice versa). It's a channel
// for byte streams.
func pipeDemo() {
r, w := io.Pipe()
// Writer goroutine:
go func() {
defer w.Close() // signal EOF to the reader
json.NewEncoder(w).Encode(map[string]int{"x": 1, "y": 2})
}()
// Reader (blocks until writer writes, then blocks again for more):
var m map[string]int
if err := json.NewDecoder(r).Decode(&m); err != nil {
log.Fatal(err)
}
fmt.Println(m) // map[x:1 y:2]
}
// Use case: stream a large JSON response to S3 without buffering it all
// in memory. The HTTP body reader writes to the pipe; S3 upload reads
// from it concurrently.
💡 Tips & Tricks
- Idiom: use
io.Copy(dst, src)for all data transfer — it handles buffering, EOF, and short reads. Don't manually loopRead+Write—io.Copyis tested and optimized. - Idiom: use
os.ReadFile/os.WriteFile(Go 1.16+) for whole-file operations — they replaceioutil.ReadFile/WriteFile(deprecated). For streaming, useos.Open+io.Copy. - Idiom: use
bufio.Scannerfor line-by-line reading — increase the buffer withscanner.Buffer(make([]byte, 0, 64*1024), maxSize)for long lines (default max is 64KB). - Performance:
io.Copyuses a 32KB internal buffer — optimal for most cases. For very large transfers,io.CopyBufferlets you reuse a buffer (zero allocation). - Safety: for write durability, call
f.Sync()beforef.Close()—Syncflushes kernel buffers to disk. Without it, a crash after Close may lose data (the OS buffer is lost). - Idiom: check
scanner.Err()after thescanner.Scan()loop — the loop exits on EOF (normal) or error;Err()tells you which.
⚠️ Edge Cases & Gotchas
Readmay returnn < len(p)witherr=nil: that's normal — don't assume the buffer is full. Always usep[:n]for the actual data.bufio.Scannerdefault max line is 64KB: lines longer than 64KB causescanner.Err()to returnbufio.ErrTooLong. Increase withscanner.Buffer(...).defer f.Close()discards the error:Closecan fail (flush error, network write). For writes, callCloseexplicitly and check the error. For reads, the discarded error is usually fine.os.WriteFiledoesn't sync: it writes and closes, but doesn't callSync. For durability, open withO_SYNCor callSyncexplicitly.io.ReadAllreads everything into memory: for large streams, this causes OOM. Useio.Copyto stream to a file orjson.NewDecoderfor streaming decode.http.Response.Bodymust be closed:defer resp.Body.Close()— leaking it leaks the TCP connection. Even on error, close it.io.Pipeis synchronous: writes block until reads consume. If the reader is slow, the writer blocks. Use a buffered channel orbytes.Bufferfor async buffering.- Short writes:
Writemay write fewer bytes than requested (n < len(p)).io.Copyhandles this; manual code must loop until all bytes are written.
🧠 Quick Quiz
go
func readConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
What's the issue if the file is 10GB?
Answer
io.ReadAll reads the ENTIRE file into memory. For a 10GB file, this allocates ~10GB of RAM — likely OOM-killed.
The fix — stream with io.Copy or bufio.Scanner:
// Stream to another writer (no full buffering):
func streamConfig(path string, w io.Writer) error {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close()
_, err = io.Copy(w, f) // streams in 32KB chunks
return err
}
// Or read line by line:
func readConfigLines(path string) error {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
// process line — only one line in memory at a time
}
return scanner.Err()
}
io.ReadAll is fine for small files (configs, JSON responses). For large files, always stream.
📚 What's Next
→ 23 — Encoding: JSON, CSV, gob — struct tags, streaming JSON, json.Number precision, and gob for Go-to-Go serialization.