Go IO Streaming Fundamentals

Go’s io package provides a uniform, composable interface for streaming data through Reader and Writer abstractions.

io.Reader

The io.Reader interface defines a minimal contract for reading sequential data:

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

The Read method fills the provided byte slice p with up to len(p) bytes. Its behavior is subtle and critical to implement correctly:

  1. Attempts to read up to len(p) bytes.
  2. n may be less than len(p) (e.g., end of stream, or partial availability).
  3. In case of error, Read may still return usable bytes in p (e.g., abrupt close of a TCP connection).
  4. On final stream exhaustion, Read may return n > 0 and err == io.EOF; afterward, all future calls return n = 0, err == io.EOF.
  5. A return of n = 0 and err == nil does not indicate end of stream—it means no data was available yet.

Because of this complexity, Go’s stendard library already includes robust Reader implementations.

strings.NewReader

Creates a Reader from a string for efficient, read-only access:

reader := strings.NewReader("Clear is better than clever")
buf := make([]byte, 4)

for {
    n, err := reader.Read(buf)
    if err != nil {
        if err == io.EOF {
            fmt.Println(string(buf[:n]))
            break
        }
        log.Fatal(err)
    }
    fmt.Println(string(buf[:n]))
}

Custom Reader Implementations

A basic implementation of an alphaReader that filters non-alphabetic characters:

type alphaFilter struct {
    source string
    pos    int
}

func (r *alphaFilter) Read(p []byte) (int, error) {
    if r.pos >= len(r.source) {
        return 0, io.EOF
    }

    count := 0
    end := min(len(p), len(r.source)-r.pos)

    for i := 0; i < end; i++ {
        ch := r.source[r.pos+i]
        if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
            p[count] = ch
            count++
        }
    }
    r.pos += end

    return count, nil
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

A more idiomatic and flexible variant uses composition with an existing io.Reader, enabling reuse across readers like os.File or net.Conn:

type alphaFilter struct {
    inner io.Reader
}

func (r *alphaFilter) Read(p []byte) (int, error) {
    n, err := r.inner.Read(p)
    if err != nil {
        return n, err
    }

    // Filter in-place for efficiency
    writeIdx := 0
    for i := 0; i < n; i++ {
        ch := p[i]
        if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
            p[writeIdx] = ch
            writeIdx++
        }
    }

    return writeIdx, nil
}

Usage with file input:

file, _ := os.Open("input.txt")
defer file.Close()
reader := &amp;alphaFilter{inner: file}

buf := make([]byte, 8)
for {
    n, err := reader.Read(buf)
    if err == io.EOF {
        break
    }
    fmt.Print(string(buf[:n]))
}

io.Writer

The io.Writer interface defines a contract for writing sequential data:

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

Example using bytes.Buffer to accumulate output:

var buffer bytes.Buffer
messages := []string{
    "Data flows through streams.",
    "Errors should be explicit.",
}

for _, msg := range messages {
    n, err := buffer.Write([]byte(msg))
    if err != nil || n != len(msg) {
        log.Fatalf("write failed: %v", err)
    }
}

fmt.Println(buffer.String())

Custom Writer with Channels

Implementing a channel-based writer for asynchronous data consumption:

type channelWriter struct {
    ch chan byte
}

func (w *channelWriter) Write(p []byte) (int, error) {
    for _, b := range p {
        w.ch <- b
    }
    return len(p), nil
}

func (w *channelWriter) Close() error {
    close(w.ch)
    return nil
}

func (w *channelWriter) Reader() <-chan byte {
    return w.ch
}

// Usage
writer := &amp;channelWriter{ch: make(chan byte, 256)}

go func() {
    defer writer.Close()
    writer.Write([]byte("Go"))
    writer.Write([]byte("er"))
}()

for b := range writer.Reader() {
    fmt.Printf("%c", b)
}
fmt.Println()

File Streams

*os.File implements both io.Reader and io.Writer, making it ideal for file streaming:

// Write to file
file, _ := os.Create("output.txt")
defer file.Close()

data := []byte("Go streams are efficient.")
n, _ := file.Write(data)
if n != len(data) {
    log.Fatal("incomplete write")
}

// Read from file
file2, _ := os.Open("output.txt")
defer file2.Close()

buf := make([]byte, 8)
for {
    n, err := file2.Read(buf)
    if err == io.EOF {
        break
    }
    fmt.Print(string(buf[:n]))
}

Standard streams (os.Stdout, os.Stdin, os.Stderr) are also *os.File instances:

os.Stdout.Write([]byte("Hello from Go!\n"))

High-Level Helpers

io.Copy

Copies data from a Reader to a Writer using an internal buffer:

src := strings.NewReader("Streaming is easy.")
dst := os.Stdout

_, err := io.Copy(dst, src)
if err != nil {
    log.Fatal(err)
}

io.WriteString
file, _ := os.Create("greeting.txt")
defer file.Close()

io.WriteString(file, "Hello, world!")

Pipe: In-Memory Stream Bridge

io.Pipe connects a Reader and Writer in-memory for goroutine-based pipelines:

reader, writer := io.Pipe()

go func() {
    defer writer.Close()
    io.WriteString(writer, "Async message")
}()

buf := make([]byte, 64)
n, _ := reader.Read(buf)
fmt.Println(string(buf[:n]))

Buffered I/O

bufio enables high-level text processing by wrapping unbuffered readers/writers:

file, _ := os.Open("lines.txt")
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}

Convenience Utilities

The legacy io/ioutil package providesBatch helpers like:

contents, err := ioutil.ReadFile("data.txt")
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(contents))

Posted on Thu, 20 Aug 2026 16:20:44 +0000 by WiseGuy