Day 39 — io interfaces

Updated

July 30, 2026

Day 39 — io interfaces

Stage V · ~3h (composition-heavy)
Goal: Internalize Go’s streaming I/O model—minimal interfaces, composition, and correct error/EOF handling—then build a transforming Reader you can plug into io.Copy.

Note

Stage V is stdlib that pays rent. Almost every network, file, and encoding path you touch for the rest of this volume is an io.Reader or io.Writer in disguise.

Why this day exists

fmt.Println and os.ReadFile hide the real model. Production Go streams bytes:

  • Files, sockets, HTTP bodies, compressors, encoders
  • Pipes between processes
  • In-memory buffers that still speak the same interfaces

If you only ever load whole files into []byte, you will write fragile services when payloads grow or when you must chain transforms.


Theory 1 — The minimal contracts

From io (simplified):

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

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

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

type WriteCloser interface {
    Writer
    Closer
}

Rules that prevent bugs

  1. Read may return n > 0 and err != nil in the same call. Process the bytes first, then the error.
  2. io.EOF means “no more data,” not failure. Callers that treat EOF as fatal are wrong.
  3. Write must report short writes. If n < len(p) and err == nil, that is a contract violation by the writer; io.ErrShortWrite exists for helpers that detect it.
  4. Interfaces are satisfied implicitly. Your type only needs the methods.

Why small interfaces win

Anything that can fill a buffer is a Reader. You can:

io.Copy(dst, src) // works for files, net.Conn, bytes.Buffer, http.Response.Body, ...

without caring what src or dst really are.


Theory 2 — Copy, Limit, Multi, Tee

io.Copy

// Copy copies from src to dst until EOF or error.
// Returns bytes written and the first error encountered (EOF is not an error).
n, err := io.Copy(dst, src)

Internally it uses a buffer (or WriterTo / ReaderFrom fast paths when available). Prefer Copy over hand-rolled loops unless you need special control.

Useful wrappers

Helper Role
io.LimitReader(r, n) At most n bytes from r
io.MultiReader(r1, r2, …) Concatenate readers
io.MultiWriter(w1, w2, …) Fan-out writes
io.TeeReader(r, w) Read from r while writing a copy to w
io.NopCloser(r) Add a no-op Close to a bare Reader
io.SectionReader Window into an io.ReaderAt

Example — cap HTTP body size

const maxBody = 1 << 20 // 1 MiB
limited := io.LimitReader(r.Body, maxBody+1)
data, err := io.ReadAll(limited)
if err != nil {
    return err
}
if len(data) > maxBody {
    return fmt.Errorf("body too large")
}

Theory 3 — io.Pipe and backpressure

pr, pw := io.Pipe()
  • Writes to pw block until someone reads from pr (buffer is small).
  • Close / CloseWithError unblocks the other side.
  • Ideal for connecting a producer that wants a Writer to a consumer that wants a Reader without buffering everything.

Pattern

pr, pw := io.Pipe()
go func() {
    defer pw.Close()
    // encode JSON, gzip, etc. into pw
    _ = json.NewEncoder(pw).Encode(payload)
}()
// pass pr to http.Request.Body or io.Copy

Always handle goroutine lifecycle and errors (CloseWithError).


Theory 4 — Building a custom Reader

A transforming reader holds an inner io.Reader and rewrites bytes as they flow.

Contract checklist for custom Read:

  1. Copy at most len(p) bytes into p.
  2. Return (n, err) with n = bytes placed in p.
  3. Preserve EOF semantics of the inner reader.
  4. Do not retain p after return (caller owns the buffer).

Worked example — rot13 stream

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
)

type rot13Reader struct {
    r io.Reader
}

func rot13(b byte) byte {
    switch {
    case b >= 'A' && b <= 'Z':
        return 'A' + (b-'A'+13)%26
    case b >= 'a' && b <= 'z':
        return 'a' + (b-'a'+13)%26
    default:
        return b
    }
}

func (r rot13Reader) Read(p []byte) (int, error) {
    n, err := r.r.Read(p)
    for i := 0; i < n; i++ {
        p[i] = rot13(p[i])
    }
    return n, err
}

func main() {
    src := strings.NewReader("Hello, Stage V!")
    var out strings.Builder
    if _, err := io.Copy(&out, rot13Reader{r: src}); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(out.String())
}

Run, then reverse by wrapping again (rot13 is involutive).


Theory 5 — ReaderFrom / WriterTo (efficiency awareness)

Optional interfaces:

type WriterTo interface {
    WriteTo(w Writer) (n int64, err error)
}

type ReaderFrom interface {
    ReadFrom(r Reader) (n int64, err error)
}

io.Copy checks for these and skips intermediate buffering when both sides support a fast path (e.g. *os.File ↔︎ net.TCPConn on some platforms via sendfile-like paths). You rarely implement these on day one—but knowing why Copy is preferred over naive loops matters.


Theory 6 — Discard and black-hole patterns

io.Copy(io.Discard, resp.Body) // drain body so connection can be reused
resp.Body.Close()

Leaving HTTP response bodies unread can stall connection reuse. Closing without draining is often wrong for keep-alive clients (Day 46 deepens this).


Lab 1 — Workspace + baseline Copy

mkdir -p ~/lab/90daysofx/01-go/day39
cd ~/lab/90daysofx/01-go/day39
go mod init example.com/day39

Create copyfile.go:

package main

import (
    "fmt"
    "io"
    "os"
)

func copyFile(dstPath, srcPath string) (int64, error) {
    src, err := os.Open(srcPath)
    if err != nil {
        return 0, err
    }
    defer src.Close()

    dst, err := os.Create(dstPath)
    if err != nil {
        return 0, err
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintf(os.Stderr, "usage: %s <src> <dst>\n", os.Args[0])
        os.Exit(2)
    }
    n, err := copyFile(os.Args[2], os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("copied %d bytes\n", n)
}
echo 'stream me' > in.txt
go run . in.txt out.txt
cmp in.txt out.txt && echo OK

Lab 2 — Custom transforming Reader

Implement upperReader that uppercases ASCII letters as data is read (reuse the rot13 pattern).

Requirements:

  1. type upperReader struct { r io.Reader } with Read method.
  2. Unit test using strings.NewReader + io.ReadAll.
  3. Composition: io.LimitReader(upperReader{r: src}, 5) and assert only 5 bytes emerge, already uppercased.
// upper_test.go
package main

import (
    "io"
    "strings"
    "testing"
)

func TestUpperReader(t *testing.T) {
    r := upperReader{r: strings.NewReader("abC12")}
    got, err := io.ReadAll(r)
    if err != nil {
        t.Fatal(err)
    }
    if string(got) != "ABC12" {
        t.Fatalf("got %q", got)
    }
}

func TestUpperLimited(t *testing.T) {
    r := io.LimitReader(upperReader{r: strings.NewReader("hello world")}, 5)
    got, err := io.ReadAll(r)
    if err != nil {
        t.Fatal(err)
    }
    if string(got) != "HELLO" {
        t.Fatalf("got %q", got)
    }
}
go test ./...

Lab 3 — Tee + MultiWriter journal

Build a small tool that reads stdin, writes a copy to a log file and stdout:

mw := io.MultiWriter(os.Stdout, logFile)
if _, err := io.Copy(mw, os.Stdin); err != nil {
    // handle
}

Or use io.TeeReader(os.Stdin, logFile) then io.Copy(os.Stdout, tee).

Document which approach buffers less for your OS (both stream; difference is API shape).


Lab 4 — Pipe encode (stretch)

In a goroutine, write a multi-line payload through json.NewEncoder(pw). In main, io.Copy(os.Stdout, pr). Ensure:

  • Producer calls pw.Close() or CloseWithError
  • Consumer sees clean EOF
  • Injecting an encode error surfaces via CloseWithError

Common gotchas

Gotcha Fix
Ignoring n when err != nil on Read Always process p[:n] first
Treating io.EOF as hard failure EOF ends the stream successfully for Copy
Buffering multi-GB files in memory Stream with Copy / limited readers
Forgetting Close on ReadCloser defer resp.Body.Close() (and drain when needed)
Retaining the Read buffer Copy out data you need; never store p
Pipe without consumer Deadlock—always have a reader side
Assuming Write always writes all Check n and err; use io.Copy helpers

Mental model diagram (text)

[source] --Read--> [transform Reader] --Read--> [LimitReader] --Copy--> [Writer]
                         ^ composition: each layer only implements Read

Checkpoint

  • Explain Read returning (n>0, err) in one sentence
  • List five concrete types that implement io.Reader
  • Custom transforming Reader tested with go test
  • Demonstrated LimitReader composition
  • Used io.Copy instead of a hand-rolled full-buffer loop
  • Can explain when to use io.Pipe

Commit

git add .
git commit -m "day39: io interfaces, composition, transforming Reader"

Write three personal gotchas before continuing.


Tomorrow

Day 40 — bufio / bytes / strings: buffering for efficient line/word scanning, bytes.Buffer, and strings.Builder for a streaming record parser.