Slice Preallocation, strings.Builder & map Pre-sizing
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.
Recipe
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/opis high in benchmarks or heap profiles- JSON/logging/middleware builds strings in a loop
- Appending to slices with known upper bound
- Maps created per request with predictable key count
Working Example
package 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.Builderamortizes growth with one backing buffer.make([]T, 0, cap)avoids repeated slice growth copies when size is estimable.
Deep Dive
How It Works
- Slice
appenddoubles capacity when full, copying elements each growth. make([]T, 0, n)allocates one backing array up tonelements.strings.Builderholds a[]bytebuffer;Growpre-extends capacity before writes.- Map growth rehashes buckets;
make(map[K]V, hint)reduces rehash steps when hint is accurate.
Sizing Guidelines
| 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 |
Go Notes
// 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.Poolis optional and only afterbenchmemproves benefit - pooled builders must beResetbefore reuse.- For known static joins,
strings.Joinis idiomatic and already optimized.
Gotchas
- Preallocating huge capacity "just in case" - Wastes RAM and can increase GC scan work. Fix: Hint from input size or measured p99, not arbitrary multiples.
- Calling
Builder.String()in a tight loop without reuse - Still allocates the final string (immutable). Fix: That is expected; eliminate intermediate strings, not the result. - Map hint far above actual keys - Large sparse maps waste memory. Fix: Use realistic distinct counts from metrics.
- Copying slices with
append([]T(nil), s...)- Allocates every time. Fix: Pre-size destination:dst := make([]T, len(s)); copy(dst, s). - Replacing
bytes.Bufferblindly -bytes.Bufferis fine for binary;strings.Builderavoids[]bytetostringcopy onString(). Fix: Pick by output type. - Optimizing tiny loops - Few-byte joins do not matter in cold paths. Fix: Profile first; keep naive code when not hot.
Alternatives
| 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 |
FAQs
Should I always set slice capacity?
Set capacity when you know or can bound final length.
For rare appends, default append clarity is fine.
Is strings.Builder thread-safe?
No.
One builder per goroutine, or guard with a mutex - pooling per request goroutine is typical.
Does Grow guarantee zero allocations?
It removes growth copies up to the grown size.
The final String() still allocates the immutable string result.
What map hint should I use?
Use expected unique keys, e.g. len(ids) when keys are IDs.
Oversized hints waste bucket memory.
Can I preallocate with make([]T, n) instead of cap?
make([]T, n) sets length n (zero values included).
Use make([]T, 0, n) when you will append n items.
How do I validate improvements?
Compare -benchmem before and after with benchstat.
Attach numbers to optimization PRs.
Does this help GC pauses?
Fewer allocations lower GC frequency and mark work.
Pair with heap profiles to confirm churn dropped.
What about append on maps in loops?
Maps do not have append.
Pre-size with make, then assign keys in the loop.
Are byte slices better than Builder for HTTP?
Write to http.ResponseWriter directly when possible.
Avoid building giant strings only to copy them to the wire.
When is naive += acceptable?
Tests, CLI output, and one-off logs with small strings.
Optimize measured hot paths only.
Related
- Escape Analysis & Compiler Optimizations - why values still heap-allocate
- Benchmarks with testing.B & B.Loop - benchmem workflow
- CPU & Heap Profiling with pprof - find alloc sites
- Performance Basics - first benchmarks
- GC Tuning for Latency-Sensitive Services - after alloc reduction
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).