String Concatenation vs strings.Builder in Go

I often leave the same comment when I see string concatenation inside a loop:

If you build a string in a loop, use strings.Builder.

That comment sometimes gets pushback from the team: the two operations probably will not matter much. Today, I want to show a simple benchmark to demonstrate why strings.Builder is superior to doing += inside a loop.

The runnable code and raw benchmark output are available in the public repo: gitlab.vvivid.dev/showcase/go-lang-case-study.

The Real Problem

Strings in Go are immutable. When we do this:

var out string
for _, part := range parts {
    out += part
}

Go cannot keep extending the same string in place. Each append creates a new string value large enough to hold the old content plus the new content, then copies data into it.

For a few strings, that cost is usually irrelevant. For a loop with many parts, it becomes more expensive than it looks.

The benchmark compares three versions:

func ConcatWithPlus(parts []string) string {
    var out string
    for _, part := range parts {
        out += part
    }
    return out
}
func ConcatWithBuilder(parts []string) string {
    var builder strings.Builder
    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String()
}
func ConcatWithBuilderGrow(parts []string) string {
    totalLen := 0
    for _, part := range parts {
        totalLen += len(part)
    }

    var builder strings.Builder
    builder.Grow(totalLen)
    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String()
}

The third version is the same idea as strings.Builder, but it tells the builder the final size before writing. That lets it allocate the backing buffer once.

The Test Setup

The correctness test is simple: every strategy must produce the same string as strings.Join(parts, "").

The benchmark uses three input sizes:

  • small: 8 parts
  • medium: 128 parts
  • large: 4,096 parts

I ran it with:

go test ./... -bench=BenchmarkStringConstruction -benchmem -count=5

Environment:

  • Go: go1.25.4
  • OS/arch: darwin/arm64
  • CPU: Apple M4 Pro

The Result

Average across five benchmark runs:

CaseStrategyns/opB/opallocs/op
small, 8 parts+=134.365447
small, 8 partsstrings.Builder72.762404
small, 8 partsstrings.Builder + Grow42.951121
medium, 128 parts+=22,684.40194,080127
medium, 128 partsstrings.Builder1,615.8011,17610
medium, 128 partsstrings.Builder + Grow610.183,0721
large, 4,096 parts+=17,958,170.00264,987,8394,101.6
large, 4,096 partsstrings.Builder53,604.60514,78522
large, 4,096 partsstrings.Builder + Grow19,980.40122,8801

The exact numbers are not the main point. They will change across machines, Go versions, and input shapes.

The shape is the point.

For 4096 parts, the easy += version allocated about 265 MB per operation. The pre-grown builder allocated about 120 KB and stayed at one allocation. That is about 2156x less memory per operation. That is the difference between repeatedly rebuilding the output string and writing into a buffer sized for the final result.

The Wrong Assumption

The wrong assumption is:

The compiler will probably optimize this away.

Sometimes Go does optimize string operations well, especially simple expressions. But a dynamic loop is a different case. The next value depends on the previous full string, so the program keeps creating larger intermediate strings.

That is why the allocation count for += follows the number of appended parts:

  • 8 parts: 7 allocations
  • 128 parts: 127 allocations
  • 4,096 parts: about 4,102 allocations

Those allocations also copy increasingly larger strings. The later appends are not just appending the new part. They are copying almost the whole result again.

The Fix

Use strings.Builder when building a string from repeated writes:

var builder strings.Builder
for _, part := range parts {
    builder.WriteString(part)
}
return builder.String()

If you know the final size, call Grow first:

var builder strings.Builder
builder.Grow(totalLen)

That is what moved the benchmark to one allocation across all input sizes.

Takeaway

You do not have to turn this into a micro-optimization. If there are only two or three values, normal concatenation is readable and fine:

name := firstName + " " + lastName

But once you are constructing a string in a loop, especially from dynamic input, strings.Builder should be the default. If you can calculate the final size cheaply, Grow is even better.

The practical rule is simple: use += for obvious small expressions, use strings.Builder for repeated construction.