Right, let’s talk about bytes. You’ve been playing with strings, and they’re lovely, but sometimes you need to get your hands dirty. Strings are immutable, which is a fancy way of saying they’re read-only. You can’t change a string; you can only create new ones. This is great for safety, but terrible for performance when you’re building something up piece by piece. That’s where bytes.Buffer comes in—it’s your mutable, in-memory scratchpad for assembling byte slices (which are often strings in disguise).

Think of a bytes.Buffer as a flexible, growing slice of bytes ([]byte) with a super-powered API attached. It’s the workhorse for any situation where you’d be tempted to do s += "new part" in a loop. If you do that with a string, you’re creating a new string allocation every single time, and the garbage collector starts giving you dirty looks. A Buffer, on the other hand, manages an internal slice and grows it intelligently, minimizing copies and keeping everyone happy.

The Mighty bytes.Buffer

You create a buffer, and you write to it. It’s that simple. You can write strings, bytes, even other byte slices. It implements the standard io.Writer and io.StringWriter interfaces, so it plays nicely with virtually everything in the Go ecosystem.

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var buf bytes.Buffer

    // Let's build a config file from bits and pieces
    buf.WriteString("name: ")
    buf.WriteString("Gopher")
    buf.WriteByte('\n')
    buf.WriteString("version: ")
    // It's an io.Writer, so Fprintf works perfectly!
    fmt.Fprintf(&buf, "%d", 1.21)
    buf.WriteByte('\n')

    fmt.Println("The buffer contains:")
    fmt.Println(buf.String())

    // Need the raw bytes? Of course you do.
    rawBytes := buf.Bytes()
    fmt.Printf("Raw bytes: %v\n", rawBytes)
}

The real beauty is when you’re reading from it. The buffer keeps an internal read cursor, so you can write a bunch of data and then read it back out, almost like a file but in memory. This is phenomenally useful for things like encoding/decoding or testing network protocols.

bytes.Reader: The Read-Only Cousin

If bytes.Buffer is for reading and writing, bytes.Reader is its read-only specialist. You give it a slice of bytes ([]byte) and it implements io.Reader, io.ReaderAt, io.Seeker, and more. It’s the perfect tool when you have a blob of data in memory and you need to pass it to a function that expects a stream.

Why not just use a slice? Because APIs are designed around interfaces, not concrete types. A function that takes an io.Reader is beautifully generic; it can work with files, network connections, and in-memory data seamlessly.

package main

import (
    "bytes"
    "fmt"
    "io"
)

func main() {
    data := []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x47, 0x6f, 0x21} // "Hello, Go!" in bytes
    reader := bytes.NewReader(data)

    // Let's read 5 bytes at a time
    chunk := make([]byte, 5)
    for {
        n, err := reader.Read(chunk)
        if err == io.EOF {
            break
        }
        fmt.Printf("Read %d bytes: %s\n", n, chunk[:n])
    }

    // We can also seek back to the beginning like it's a file!
    reader.Seek(0, io.SeekStart)
    firstByte, _ := reader.ReadByte()
    fmt.Printf("First byte again: %c\n", firstByte)
}

Slicing and Dicing with bytes and strings

The bytes package mirrors the strings package almost exactly. bytes.Contains, bytes.Split, bytes.TrimSpace—they all exist. The key difference is they operate on []byte instead of string. This is crucial because converting between string and []byte isn’t free; it involves an allocation and a copy. So if you’re doing heavy textual manipulation on data that’s already a byte slice (e.g., from a network read), you should use bytes to avoid converting it to a string and back.

Here’s a classic pitfall: modifying a slice from .Bytes(). The slice returned by buf.Bytes() is a direct view into the buffer’s internal memory. This is incredibly efficient, but also dangerous.

// WARNING: This is a trap.
content := buf.Bytes()
content[0] = 'J' // This directly modifies the buffer's internal state!

// This is safer if you need to modify the result independently.
contentCopy := make([]byte, len(content))
copy(contentCopy, buf.Bytes())
contentCopy[0] = 'J' // The original buffer is untouched.

The designers made a choice here: performance over safety. It’s the right choice for a low-level package, but you have to know about it. It’s not a questionable choice, it’s a deliberate one that gives you power, and with power comes responsibility. Don’t blame them when you shoot your own foot off; they gave you the safety manual.