Right, let’s talk about strings. You love them, I love them, the Go runtime tolerates them. They’re the duct tape of our programs, holding everything together until they suddenly become the number one reason your elegant service is now gasping for memory like a fish on a sidewalk.

The fundamental problem is that strings in Go are immutable. This is a fantastic feature for concurrency and safety, but a real pain when you’re building them up in a hot loop. Every time you write s += "new piece", you’re not just appending; you’re allocating a whole new string, copying both s and "new piece" into it, and then sending the old s off to be cleaned up by the garbage collector (GC). Do this a few thousand times and your GC is going to be working overtime, putting a serious damper on your throughput.

The Classic Pitfall: Naive String Concatenation

Let’s see this horror in action. Don’t run this on a production server, or your SRE might personally revoke your coffee privileges.

// Don't do this. Seriously.
func buildStringNaively(parts []string) string {
    var result string
    for _, part := range parts {
        result += part // Each iteration allocates a new string!
    }
    return result
}

To understand the absurdity, imagine you’re building a string from 1000 parts. The first iteration copies 1 part. The second copies 2. The third, 3. By the end, you’ve copied 1+2+3+...+1000 parts. That’s 500,500 copies for a 1000-part string! This is O(n²) complexity, and it’s why we have better tools.

bytes.Buffer: The Old Reliable

Enter bytes.Buffer. This is your workhorse. It’s a mutable slice of bytes ([]byte) with a powerful, flexible API. You write to the buffer, it manages the underlying array, handling allocations and copies for you efficiently by growing its capacity in smart chunks. When you’re done, you call String() to get the final, immutable string. The conversion is efficient because the Go compiler recognizes this pattern and makes it a cheap O(n) operation.

func buildStringWithBuffer(parts []string) string {
    var buf bytes.Buffer
    for _, part := range parts {
        buf.WriteString(part) // Appends to the internal slice. Much smarter.
    }
    return buf.String()
}

It’s straightforward, battle-tested, and gets the job done. But it’s a bit, well, general. It has methods for writing bytes, reading, and even implementing interfaces like io.Writer. This flexibility comes with a tiny bit of overhead.

strings.Builder: The New Specialist

Go 1.10 introduced strings.Builder, which is essentially bytes.Buffer but laser-focused on one task: building strings. It strips out all the read functionality, making it slightly more efficient and its intent perfectly clear.

Its secret weapon is the []byte slice it uses under the hood. When you call String(), it uses an unsafe pointer conversion (*(*string)(unsafe.Pointer(&b.buf))) to cast the slice directly to a string without copying. This is safe because a string is, by definition, immutable, and the underlying bytes are never modified again after the call to String(). It’s a brilliant, performant hack.

func buildStringWithBuilder(parts []string) string {
    var builder strings.Builder
    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String() // Super efficient final conversion
}

So, which one should you use? For only building strings, strings.Builder is the modern, idiomatic choice. It’s a tiny bit faster and makes your intent explicit. If you need to mix in other operations, like writing to an io.Writer or reading from the buffer, bytes.Buffer is still your tool.

The Real Magic: Preallocating Size

Here’s the pro move that both Buffer and Builder enable: preallocation. The single biggest performance gain often comes from avoiding repeated small allocations as the buffer grows. If you know even an approximate size of the final string, tell the builder!

func buildStringEfficiently(parts []string) string {
    total := 0
    for _, part := range parts {
        total += len(part)
    }

    var builder strings.Builder
    builder.Grow(total) // This is the key! One allocation up front.
    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String()
}

Calling Grow() ensures the underlying slice has enough capacity from the get-go. This eliminates all the intermediate copy operations during the append phase. It’s the difference between the builder having to move to a new, bigger apartment five times during your lease versus just moving into a perfectly-sized one once.

A Word on String Interning

You might have heard of “string interning” in other languages (like Java’s string pool), where identical string values are stored once in memory to save space. Go, in its pragmatic wisdom, does not have a automatic runtime-wide string interning mechanism.

Why? Because interning is a trade-off. It saves memory at the potential cost of CPU cycles (managing the intern pool has overhead) and complexity. The Go designers decided it wasn’t a universal win and opted for simplicity. If you need it, you have to build it yourself, typically with a map[string]string or sync.Map guarded by a mutex. But before you do, ask yourself: are you sure you have a measurable problem with duplicate strings? Profile first. You probably don’t need it. The combination of efficient builders and letting short-lived strings be cleaned up by the GC is usually the right call.

The bottom line is this: stop using += for building strings in loops. It’s 2024. Use strings.Builder, use Grow() if you can, and let your GC focus on real problems.