How to Optimize String Concatenation in Go

Use strings.Builder instead of the + operator to efficiently concatenate strings in Go without excessive memory allocation.

The string concatenation trap

You are processing a batch of log entries. Each entry needs a timestamp, a user ID, and an action stitched together. You write a loop that reads the data and builds the output string using the + operator. The code runs instantly on your sample file. You point it at the production dataset with fifty thousand records. The CPU usage climbs. Memory allocation spikes. The garbage collector runs constantly, pausing the program. The bottleneck isn't your I/O. It's the string building.

Strings are immutable, builders are mutable

Go strings are immutable. Once created, a string cannot change. When you write a + b, the runtime allocates a new buffer, copies the bytes from a, copies the bytes from b, and returns the new string. The original strings remain until the garbage collector reclaims them. In a loop, this pattern creates a cascade of allocations. Each iteration allocates a larger buffer and copies all previous data. The cost grows quadratically.

strings.Builder solves this by holding a mutable byte slice. You write parts into the buffer. The builder manages the underlying storage, resizing only when necessary. You call .String() once at the end to get the final immutable result. Think of a string as a printed photograph. You cannot edit a photo. To add text, you must print a new photo. strings.Builder is a whiteboard. You write, erase, and add until you are done, then you take a picture of the result.

Strings are cheap to pass by value. A string is just a pointer to data plus a length. Passing a string copies those two words, which is fast. Don't pass a *string to save memory. The pointer overhead defeats the purpose. Builders are for construction, not storage.

Minimal example

Here's the simplest way to build a string without intermediate allocations.

package main

import (
	"fmt"
	"strings"
)

func main() {
	// Builder holds a mutable byte slice for efficient writes.
	var sb strings.Builder
	sb.WriteString("Hello, ")
	sb.WriteString("World!")
	// String() creates the final immutable string from the buffer.
	result := sb.String()
	fmt.Println(result)
}
# output:
Hello, World!

How the builder manages memory

The builder starts with a small internal buffer. When you call WriteString, it checks if the new data fits. If it fits, the builder writes the bytes and advances the length. If the buffer is full, the builder allocates a larger buffer, copies the existing data, and writes the new bytes. This growth strategy amortizes the cost of reallocations. You pay for resizing occasionally, but the average cost per write stays low.

If you know the approximate total length, call Grow before the loop. Grow reserves capacity upfront. The builder skips reallocations until the buffer exceeds the reserved size. This is especially effective when concatenating a known number of parts. Without Grow, the builder might resize multiple times, copying data repeatedly. With Grow, you pay for one allocation and zero copies.

Over-estimating Grow wastes memory but doesn't hurt correctness. Under-estimating triggers reallocations. A rough guess is better than no guess. The builder also has a Reset method. If you reuse a builder in a tight loop, call Reset to clear the buffer without freeing memory. This lets you reuse the allocation for the next string. The builder tracks length and capacity separately. Len returns the number of bytes written. Cap returns the total allocated size. You can check Cap to debug allocation patterns.

Guess the size. Pay for one allocation, not ten.

The compiler's secret optimization

The compiler is smart about simple cases. If you write s := a + b + c where the variables are known, the compiler calculates the total length, allocates a single buffer, and copies the parts in one pass. There are no intermediate strings. The trap appears only in loops or when the length is unknown at compile time. In a loop, the compiler cannot predict the final size. It falls back to the runtime + operator, which triggers the allocation cascade. strings.Builder gives you control over the allocation strategy regardless of loop structure.

Realistic example: building a query string

Here's a function that constructs an HTTP query string from a map. It uses Grow to estimate capacity and Fprintf to format integers directly into the buffer.

// BuildQuery creates a URL-encoded query string from parameters.
func BuildQuery(params map[string]int) string {
	// Estimate capacity to minimize reallocations during the loop.
	var sb strings.Builder
	sb.Grow(len(params) * 20)

	first := true
	for key, value := range params {
		if !first {
			sb.WriteString("&")
		}
		sb.WriteString(key)
		sb.WriteString("=")
		// Fprintf writes formatted output directly to the builder.
		fmt.Fprintf(&sb, "%d", value)
		first = false
	}
	return sb.String()
}

The function name BuildQuery starts with a capital letter, making it public. Go uses capitalization for visibility, not keywords like public. Lowercase names are private to the package. The builder implements io.Writer, so you can pass &sb to any function that writes to an io.Writer. This includes fmt.Fprintf, io.Copy, and many standard library functions. It makes the builder composable. fmt.Fprintf writes formatted values directly to the buffer without creating intermediate strings. This avoids the extra allocation that fmt.Sprintf would produce. fmt.Sprintf creates a string, then you'd have to write that string to the builder. Fprintf skips the intermediate string.

Pitfalls and conventions

The compiler rejects s := "id=" + 42 with cannot use 42 (untyped int constant) as string value in argument. You must format the integer first. strings.Builder supports fmt.Fprintf, which writes formatted values directly to the buffer without creating intermediate strings. This avoids the extra allocation that fmt.Sprintf would produce.

Methods on strings.Builder return an error. The convention is to check the error, even though failures are rare in normal usage. The error indicates the buffer is corrupted or the write failed. Ignoring the error works in simple scripts, but production code should handle it. The boilerplate makes the failure path visible. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.

// Check errors from builder methods in production code.
if _, err := sb.WriteString(part); err != nil {
	return err
}

strings.Builder is not safe for concurrent use. Do not share a builder across goroutines. Each goroutine should create its own builder. Sharing a builder without synchronization causes data races and corrupted output. Go methods use short receiver names. You'll see (b *Builder) WriteString(...) in the source, not (this *Builder). The convention is one or two letters matching the type.

When you have a slice of strings, use strings.Join instead of a loop. strings.Join calculates the total length, allocates once, and copies all parts. It is faster and more readable than a manual loop with a builder. The community treats strings.Join as the idiomatic way to combine slices. bytes.Buffer is an alternative for general-purpose byte manipulation. Use bytes.Buffer when you need to read from the buffer or mix binary data. strings.Builder is faster for pure string construction because it avoids some overhead and assumes UTF-8 text.

Don't share builders across goroutines. Data races corrupt your output.

Decision matrix

Use + when concatenating a small, fixed number of strings known at compile time. The compiler optimizes this pattern automatically.

Use strings.Builder when building a string in a loop or when the total length is dynamic.

Use strings.Join when combining a slice of strings with a delimiter.

Use fmt.Sprintf when you need complex formatting verbs and the resulting string is short.

Use bytes.Buffer when you need to mix binary data with text or when you need to read from the buffer as well.

Pick the tool that matches the shape of your data.

Where to go next