Slice Preallocation, strings.Builder & map Pre-sizing
Many Go hot paths spend more time allocating than computing.
Search across all documentation pages
Many Go hot paths spend more time allocating than computing.
Pre-size slices and maps when length is known, build strings with strings.Builder, and prove wins with -benchmem instead of guessing.
Quick-reference recipe card - copy-paste ready.
// Slice: capacity hint
out := make([]int, 0, len(in))
// Map: size hint
m := make(map[string]int, len(keys))
// String: Builder with optional Grow
var b strings.Builder
b.Grow(estimatedLen)
b.WriteString("hello")
_ = b.String()go test -bench=. -benchmem ./...When to reach for this:
allocs/op is high in benchmarks or heap profilespackage alloc
import (
"strings"
"testing"
)
func JoinNaive(parts []string) string {
s := ""
for _, p := range parts {
s += p + ","
}
return s
}
func JoinBuilder(parts []string) string {
var b strings.Builder
b.Grow(len(parts) * 8)
for i, p := range parts {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(p)
}
return b.String()
}
func FilterNaive(in []int) []int {
var out []int
for _, v := range in {
if v%2 == 0 {
out = append(out, v)
}
}
return out
}
func FilterPrealloc(in []int) []int {
out := make([]int, 0, len(in)/2)
for _, v := range in {
if v%2 == 0 {
out = append(out, v)
}
}
return out
}
func BenchmarkJoinNaive(b *testing.B) {
parts := []string{"alpha", "beta", "gamma", "delta"}
b.ReportAllocs()
for b.Loop() {
JoinNaive(parts)
}
}
func BenchmarkJoinBuilder(b *testing.B) {
parts := []string{"alpha", "beta", "gamma", "delta"}
b.ReportAllocs()
for b.Loop() {
JoinBuilder(parts)
}
}What this demonstrates:
+= in loops allocates a new string each iteration.strings.Builder amortizes growth with one backing buffer.make([]T, 0, cap) avoids repeated slice growth copies when size is estimable.append doubles capacity when full, copying elements each growth.make([]T, 0, n) allocates one backing array up to n elements.strings.Builder holds a []byte buffer; Grow pre-extends capacity before writes.make(map[K]V, hint) reduces rehash steps when hint is accurate.| Structure | Hint source | Risk if wrong |
|---|---|---|
[]T output | len(input) or filtered fraction | Wasted cap uses memory |
map[K]V | distinct key count | Over-hint wastes buckets |
Builder.Grow | sum of part lengths + separators | Under-grow still grows, just later |
// Reuse Builder - Reset clears buffer but keeps capacity
var builderPool sync.Pool
builderPool.New = func() any { return new(strings.Builder) }
func FormatID(id int) string {
b := builderPool.Get().(*strings.Builder)
b.Reset()
b.WriteString("id:")
b.WriteString(strconv.Itoa(id))
s := b.String()
builderPool.Put(b)
return s
}sync.Pool is optional and only after benchmem proves benefit - pooled builders must be Reset before reuse.strings.Join is idiomatic and already optimized.Builder.String() in a tight loop without reuse - Still allocates the final string (immutable). Fix: That is expected; eliminate intermediate strings, not the result.append([]T(nil), s...) - Allocates every time. Fix: Pre-size destination: dst := make([]T, len(s)); copy(dst, s).bytes.Buffer blindly - bytes.Buffer is fine for binary; strings.Builder avoids []byte to string copy on String(). Fix: Pick by output type.| Alternative | Use When | Don't Use When |
|---|---|---|
strings.Join | Known []string once | Building incrementally with logic between parts |
bytes.Buffer | Binary protocols | Final output must be string without copy |
Fixed [N]byte stack buffer | Tiny formatted IDs | Size unknown or large |
sync.Pool for []byte | Encoder buffers in RPC path | Rare use - pool overhead wins |
Set capacity when you know or can bound final length.
For rare appends, default append clarity is fine.
No.
One builder per goroutine, or guard with a mutex - pooling per request goroutine is typical.
It removes growth copies up to the grown size.
The final String() still allocates the immutable string result.
Use expected unique keys, e.g. len(ids) when keys are IDs.
Oversized hints waste bucket memory.
make([]T, n) sets length n (zero values included).
Use make([]T, 0, n) when you will append n items.
Compare -benchmem before and after with benchstat.
Attach numbers to optimization PRs.
Fewer allocations lower GC frequency and mark work.
Pair with heap profiles to confirm churn dropped.
Maps do not have append.
Pre-size with make, then assign keys in the loop.
Write to http.ResponseWriter directly when possible.
Avoid building giant strings only to copy them to the wire.
Tests, CLI output, and one-off logs with small strings.
Optimize measured hot paths only.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026