I/O and Streaming
I/O and Streaming
Go I/O is two interfaces: io.Reader (Read) and io.Writer (Write). Files, buffers, HTTP bodies, and os.Stdout all speak them. The boring default is: copy streams with io.Copy, wrap with bufio when you read lines, and close what you open.
Mental model
Read fills a slice and returns how many bytes and an error. io.EOF means “no more.” It is not a failure of the program; it is the end of the stream. Write takes a slice and returns how many bytes were accepted.
io.Copy(dst, src) is the loop you should not write by hand. bytes.Buffer is an in-memory Reader and Writer. bufio.Scanner splits a reader into lines (or other tokens). os.Open opens a file for reading. os.CreateTemp makes a unique file in the temp directory — use it in examples and tests so the program does not depend on a path on your desk.
Always check the error from Close on writers. A buffered write can fail at flush.
Worked examples
Case 1: Reader to Writer
Save as copy_tickets.go. A string is a reader. A buffer is a writer. io.Copy moves the bytes.
// copy_tickets.go
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("ticket 7\nticket 8\n")
var dst bytes.Buffer
n, err := io.Copy(&dst, src)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("bytes", n)
fmt.Print(dst.String())
}Run:
go run copy_tickets.goOutput:
bytes 18
ticket 7
ticket 8
strings.NewReader never fails. A file or a socket might. Keep the err check anyway so the code looks like production.
Case 2: bytes.Buffer as a builder
Save as buffer_note.go. Write pieces, then take the string.
// buffer_note.go
package main
import (
"bytes"
"fmt"
)
func main() {
var b bytes.Buffer
b.WriteString("ticket 7")
b.WriteByte(':')
b.WriteString(" no onions")
fmt.Println(b.String())
}Run:
go run buffer_note.goOutput:
ticket 7: no onions
Buffer grows as needed. For a few strings, fmt.Sprintf or strings.Builder is also fine. Use Buffer when you already have io.Writer code.
Case 3: bufio.Scanner
Save as scan.go. Line at a time.
// scan.go
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
r := strings.NewReader("7\n8\n9\n")
s := bufio.NewScanner(r)
for s.Scan() {
fmt.Println("ticket", s.Text())
}
if err := s.Err(); err != nil {
fmt.Println(err)
}
}Run:
go run scan.goOutput:
ticket 7
ticket 8
ticket 9
Scan returns false on EOF or error. Check s.Err() after the loop. Default split is lines. Default max token is 64KiB — a giant line needs a larger buffer or a different reader.
Case 4: CreateTemp, write, Open, read
Save as temp_order.go. The program makes its own file, writes an order, opens it again, and prints the contents. No path on your machine is required.
// temp_order.go
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.CreateTemp("", "order-*.txt")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
name := f.Name()
defer os.Remove(name)
if _, err := f.WriteString("table 12, ticket 7\n"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := f.Close(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
in, err := os.Open(name)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer in.Close()
got, err := io.ReadAll(in)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Print(string(got))
}Run:
go run temp_order.goOutput:
table 12, ticket 7
CreateTemp picks a unique name. defer os.Remove(name) cleans up even if a later check fails. os.Open is read-only. WriteString then Close the writer before Open so the bytes are on disk.
Case 5: Stream lines from the temp file
Save as temp_scan.go. Same file, bufio instead of ReadAll.
// temp_scan.go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
f, err := os.CreateTemp("", "tickets-*.txt")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
name := f.Name()
defer os.Remove(name)
if _, err := f.WriteString("7\n8\n9\n"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := f.Close(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
in, err := os.Open(name)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer in.Close()
s := bufio.NewScanner(in)
for s.Scan() {
fmt.Println("print", s.Text())
}
if err := s.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Run:
go run temp_scan.goOutput:
print 7
print 8
print 9
This is the shape for a log or a large order file: do not ReadAll unless the size is known and small.
The trap
Save as forget_close.go. The write is buffered in the file’s world only after Close (or Sync). This program reads the same file handle without seeking back, so it looks empty.
// forget_close.go
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.CreateTemp("", "stale-*.txt")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer os.Remove(f.Name())
defer f.Close()
if _, err := f.WriteString("ticket 7\n"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
got, err := io.ReadAll(f)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("read %d bytes\n", len(got))
}Run:
go run forget_close.goOutput:
read 0 bytes
The cursor is at the end. Nothing to read. Fix: f.Seek(0, io.SeekStart) before ReadAll, or Close and os.Open as in Case 4. The same class of bug: never calling Close on a bufio.Writer (data stuck in the buffer). Call Flush or Close on the wrapper.
The boring rule
- Talk to
io.Reader/io.Writer, not to a concrete file, unless you need file-only methods. io.Copyfor whole streams.bufio.Scannerfor lines.- Check
Scanner.Err. Treatio.EOFas the end, not as a crash. CreateTempin programs that should not hard-code a path.- Close writers; check the error.
defer f.Close()is right for reads; for writes, close explicitly if the next step depends on the bytes being there. - Do not
ReadAlla stream you can process incrementally.
Try this
- In
copy_tickets.go, copy toos.Stdoutinstead of a buffer. Drop thebytesprint or keep aio.TeeReaderif you still want the count. - In
scan.go, add a blank line in the middle of the string. See thatScanstill yields an emptyText(). - In
temp_order.go, printlen(got)as well as the string. - Fix
forget_close.gowithf.Seek(0, io.SeekStart)beforeReadAll. Confirm you read 9 bytes.