Right, let’s talk about strings.Reader. You’ve got a string. It’s sitting there in memory, looking perfectly innocent. You need to read from it, maybe seek around in it, treat it like a stream of data. You could keep track of an index variable yourself, slicing and dicing until your code looks like a deli counter on a Saturday morning. Or, you could be civilized and use strings.Reader.

Think of a strings.Reader as giving your string an identity crisis: it desperately wants to be an io.Reader, an io.ReaderAt, an io.Seeker, and an io.ByteScanner. And the wonderful part is, it succeeds brilliantly. It wraps a string and provides a read pointer, letting you treat that immutable string as a consumable, seekable stream. It’s one of those beautifully simple pieces of the standard library that just works exactly how you’d hope.

The Obvious Way to Make One: strings.NewReader

You don’t actually need to know this, but I’m going to tell you anyway: under the hood, a strings.Reader is basically a struct holding the string itself and a current index (int), and it uses that index for all its operations. The easiest way to get one is with strings.NewReader.

package main

import (
    "fmt"
    "strings"
)

func main() {
    const story = "The quick brown fox jumps over the lazy dog."
    reader := strings.NewReader(story)

    // Let's read the first 10 bytes into a buffer
    buf := make([]byte, 10)
    n, err := reader.Read(buf)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Read %d bytes: %s\n", n, string(buf[:n]))
    // Output: Read 10 bytes: The quick
}

The key thing to notice here is that after this Read operation, the reader’s internal pointer has advanced. The next read will pick up where it left off. It’s stateful, just like reading from a network connection or a file.

What’s the Point? (Or, The io.Reader Sandwich)

The primary superpower of strings.Reader is its conformance to a suite of extremely common interfaces. This makes it the perfect stand-in for testing or for any function that expects, say, an io.Reader.

Imagine you have a function that parses data from a stream. You could open a real file to test it. But that’s slow, messy, and makes your tests dependent on the filesystem. Instead, you can use a strings.Reader to create a test fixture directly from a string literal. It’s the ultimate convenience food for your functions that crave an io.Reader.

// This function doesn't care if it's reading from a network socket,
// a file, or a string. An io.Reader is an io.Reader.
func parseUntilComma(r io.Reader) (string, error) {
    // ... implementation that reads bytes until it finds a ','
    return result, nil
}

// Testing it is trivial.
func TestParseUntilComma(t *testing.T) {
    testInput := "hello,world"
    result, err := parseUntilComma(strings.NewReader(testInput))
    if err != nil {
        t.Fatalf("Unexpected error: %v", err)
    }
    if result != "hello" {
        t.Fatalf("Expected 'hello', got '%s'", result)
    }
}

The Seek is Why You’re Here

While being an io.Reader is great, the io.Seeker interface is where strings.Reader really starts to show off. You can jump around in your string with impunity.

reader := strings.NewReader("ABCDEFGHIJKLMNOPQRSTUVWXYZ")

// Read the first 5 bytes: "ABCDE"
buf1 := make([]byte, 5)
reader.Read(buf1)

// Now, seek back to the absolute beginning (offset 0)
offset, err := reader.Seek(0, io.SeekStart)
// offset is now 0

// Seek to the 10th byte from the start
offset, err = reader.Seek(10, io.SeekStart)
// offset is now 10, next read starts at 'K'

// The SeekCurrent and SeekEnd constants work exactly as you'd expect.
// Jump to the 5th byte from the end? Sure.
offset, err = reader.Seek(-5, io.SeekEnd)
// offset is now len("ABCDEFGHIJKLMNOPQRSTUVWXYZ") - 5 = 21, next read starts at 'V'

This makes it incredibly useful for parsing formats where you might need to peek ahead, backtrack, or re-read a section without having to create a whole new reader.

The One “Gotcha” (It’s Not a Big One)

Remember, a string is immutable. A strings.Reader is a read-only view. You cannot modify the original string through the reader. This is almost always what you want, but it’s worth stating explicitly. All the Read methods return bytes, they don’t change the source.

Also, a minor point about efficiency: when you call Len(), it returns the number of unread bytes. This is calculated as len(s) - current_index. It’s a constant-time operation, which is nice, but just be aware it’s telling you what’s left in the buffer, not the original length. Use Size() (which it also implements from io.Seeker) to get the original absolute length.

Beyond the Basics: The Other Methods

Don’t just use it for Read. It has a perfectly good ReadByte method if you’re consuming a stream byte-by-byte. It also implements WriteTo, which is a fantastic optimization. This allows the reader to dump its entire remaining contents directly into an io.Writer (like a bytes.Buffer or a file) without bouncing the data through your user-space buffer. The strings.Reader itself handles the copy in the most efficient way possible.

reader := strings.NewReader("Some large string data...")
var buffer bytes.Buffer

written, err := reader.WriteTo(&buffer)
// The entire contents of the reader are now in `buffer.Bytes()`
// and the reader is fully exhausted.

In a world full of overly complex abstractions, strings.Reader is a masterpiece of simple, focused utility. You get a string. You need a stream. Problem solved. Use it everywhere.