I/O and Streaming

Updated

September 13, 2026

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.Copy for whole streams. bufio.Scanner for lines.
  • Check Scanner.Err. Treat io.EOF as the end, not as a crash.
  • CreateTemp in 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 ReadAll a stream you can process incrementally.

Try this

  1. In copy_tickets.go, copy to os.Stdout instead of a buffer. Drop the bytes print or keep a io.TeeReader if you still want the count.
  2. In scan.go, add a blank line in the middle of the string. See that Scan still yields an empty Text().
  3. In temp_order.go, print len(got) as well as the string.
  4. Fix forget_close.go with f.Seek(0, io.SeekStart) before ReadAll. Confirm you read 9 bytes.