Introduction

When I write Go code that deals with textual data—whether it's assembling a JSON payload, preparing a log line, or constructing a large response body—I quickly discover that naive string concatenation can become a performance bottleneck. The standard library provides a simple, yet powerful tool for this exact scenario: strings.Builder. In this article I'll show you why it's worth adding to your toolkit, walk through a realistic example, and share a few best‑practice tips you can drop into production code right away.

Why strings.Builder?

Go strings are immutable. Each time you use the + operator or fmt.Sprintf to combine pieces, you allocate a new backing array and copy the old content. If you're stitching together, say, a thousand short fragments, the cumulative copying can dominate CPU time and cause unnecessary GC pressure.

  • Single allocation when you know the approximate size (you can Grow upfront).
  • Zero‑copy writes via the WriteString and WriteByte methods.
  • It satisfies the io.Writer interface, so you can plug it into existing functions that expect a writer.

These properties make strings.Builder the go‑to choice for building strings in high‑throughput code paths. It's not just a micro‑optimization; it scales linearly with the amount of data, which matters for services that handle large payloads.

A Real‑World Scenario

Imagine a RESTful API that receives a list of user IDs and returns a comma‑separated list of active users. The original implementation used a slice of strings and strings.Join:

ids := []string{"1","2","3","4"}
result := strings.Join(ids, ",")
// result = "1,2,3,4"

That works fine for a handful of IDs, but what if the request contains thousands of IDs? strings.Join still iterates over the slice and allocates a single large string, which is okay but not optimal when you already have the pieces in memory. A more flexible pattern is to accumulate the IDs as you iterate over a database cursor, and you might also need to prepend a prefix or append metadata.

Here’s how I’d refactor that logic using strings.Builder:

var sb strings.Builder
// Pre‑allocate enough capacity to avoid reallocations.
// 10 bytes per ID + 1 comma per extra ID.
sb.Grow(len(ids) * 11)
for i, id := range ids {
    if i > 0 {
        sb.WriteByte(',')
    }
    sb.WriteString(id)
}
result := sb.String()

The Grow call is optional but useful when you can estimate the final size. It eliminates the need for the builder to repeatedly double its internal buffer, saving both time and allocations.

When building strings from many fragments, strings.Builder typically reduces allocation count by >90% compared to repeated + concatenation.

Production‑Ready Example: JSON Payload Builder

Let’s look at a concrete production example: constructing a JSON object that aggregates metrics from several sources. Instead of using fmt.Sprintf for each field, I prefer a builder because the final payload can be several kilobytes and is generated per request.

import (
    "encoding/json"
    "strings"
)

type Metric struct {
    Name  string
    Value float64
}

func BuildMetricsJSON(metrics []Metric) (string, error) {
    var sb strings.Builder
    // Write the opening brace and first field without a trailing comma.
    sb.WriteString(`{"metrics":[`)
    for i, m := range metrics {
        if i > 0 {
            sb.WriteByte(',')
        }
        // Use the builder directly as an io.Writer for json.NewEncoder.
        enc := json.NewEncoder(&sb)
        // Set indentation only for debugging; omit for production.
        enc.SetEscapeHTML(false)
        if err := enc.Encode(m); err != nil {
            return "", err
        }
        // json.Encoder adds a newline after each object; strip it.
        if sb.Len() > 0 && sb.String()[sb.Len()-1] == '\n' {
            sb.Truncate(sb.Len() - 1)
        }
    }
    sb.WriteString(`]}`)
    return sb.String(), nil
}

The function first pre‑allocates a reasonable buffer size (you can compute it based on the slice length). It then iterates once, writing each metric as a JSON object directly into the builder. Because json.NewEncoder accepts any io.Writer, we avoid an intermediate byte slice. The final string is ready for network transmission without extra copies.

Best Practices & Gotchas

  • Pre‑allocate with Grow. If you know the approximate final length, call Grow to set the internal buffer capacity. This avoids repeated reallocations as the builder grows.
  • Prefer WriteString over WriteByte for multi‑character fragments. The builder internally uses a byte slice, so writing a whole string at once is cheaper than looping over bytes.
  • Remember immutability. Once you call String(), the builder resets its internal buffer for reuse, but the returned string must be treated as immutable. If you need a mutable copy, create a new builder or slice.
  • Combine with bytes.Buffer when binary data is involved. If you need to write both text and binary, bytes.Buffer offers the same API and can be cast to io.Writer.

A common mistake is to forget that String() returns a copy of the builder's buffer; subsequent writes do not affect the returned string. If you need to keep appending after you’ve called String(), store the builder and call String() only at the very end.

Summary

The strings.Builder type is a small but mighty addition to the Go standard library. By providing a reusable, allocator‑friendly way to assemble strings, it solves a frequent performance pain point without sacrificing readability. Whether you're joining IDs, crafting log messages, or streaming JSON payloads, swapping out naive concatenation for a builder will typically cut allocation overhead and improve throughput—exactly the kind of incremental win that adds up in a high‑traffic service.

I encourage you to profile your code when you suspect string building is a bottleneck, then give strings.Builder a try. You'll likely see the benefits in both benchmarks and real‑world latency metrics.