The allocation trap
You are writing a log parser. It works perfectly with 100 lines. You scale to 10 million lines, and the CPU usage spikes to 100 percent. The profiler shows the garbage collector running every 50 milliseconds. Your latency jumps from 2 milliseconds to 200 milliseconds. The algorithm is correct. The logic is sound. The problem is memory.
Your code creates millions of tiny string allocations and throws them away. Go's garbage collector is concurrent and efficient, but it is not free. Every allocation requires the runtime to find a free block, update bookkeeping structures, and eventually scan that memory to determine if it is still alive. When allocations outpace the collector, the program spends more time managing memory than doing work. Reducing allocations is often the single fastest way to improve performance in Go.
How memory works in Go
Memory allocation is like checking out a book from a library. You ask the librarian for a book, they find a spot on the shelf, mark it as yours, and hand it over. When you are done, you return it. The librarian eventually cleans up the returned books and reorganizes the shelf.
If you check out a book, read one page, and return it a million times a second, the librarian gets exhausted. They spend all their time stamping cards and shuffling books instead of helping other readers. Go uses a concurrent garbage collector to handle the cleanup, but the collector still has to scan memory, mark live objects, and sweep dead ones. Fewer allocations mean less work for the collector and more time for your code.
The garbage collector is a helper, not a magic wand.
String concatenation
Strings in Go are immutable. Once created, a string cannot change. This makes strings safe for use in maps and concurrent code, but it creates a trap when building strings dynamically.
Here is the classic mistake: building a string in a loop with +=.
package main
import (
"fmt"
"strings"
)
// ConcatLoop builds a string by appending in a loop.
func ConcatLoop(n int) string {
var result string
for i := 0; i < n; i++ {
// Each concatenation allocates a new string and copies the old content.
result += "a"
}
return result
}
// BuilderLoop uses strings.Builder to avoid intermediate allocations.
func BuilderLoop(n int) string {
// Builder pre-allocates a buffer and reuses it.
var sb strings.Builder
for i := 0; i < n; i++ {
// WriteByte appends to the internal buffer without creating a new string.
sb.WriteByte('a')
}
// String returns the final immutable string from the buffer.
return sb.String()
}
func main() {
fmt.Println(ConcatLoop(1000))
fmt.Println(BuilderLoop(1000))
}
When you write result += "a", the runtime allocates a new string large enough to hold the old content plus the new character. It copies the old data, appends the character, and discards the old string. In a loop of size N, this creates N allocations and copies O(N^2) bytes total.
strings.Builder keeps a mutable byte slice inside. It grows the slice only when needed. You write to the builder, and when you are done, you call String() to get the final result. One allocation instead of N.
The compiler optimizes fixed concatenations. s := "hello" + " " + "world" becomes a single allocation at compile time. The trap only exists in loops or when the size is dynamic.
Builder reuses the buffer. Concatenation creates garbage.
Reusing objects with sync.Pool
When you need to allocate objects frequently in a hot path, sync.Pool lets you recycle them. The pool holds a collection of temporary objects that can be individually retrieved and returned for later use.
Here is how to pool byte buffers for request processing.
package main
import (
"bytes"
"sync"
)
// bufPool holds reusable byte buffers.
var bufPool = sync.Pool{
// New creates a fresh buffer only when the pool is empty.
New: func() any {
// Allocate a zero-value buffer with default capacity.
return &bytes.Buffer{}
},
}
// ProcessRequest simulates handling a request that needs a buffer.
func ProcessRequest(data []byte) []byte {
// Get reuses a buffer from the pool or creates a new one.
buf := bufPool.Get().(*bytes.Buffer)
// Reset clears the content but keeps the underlying capacity.
buf.Reset()
// Write data to the buffer.
buf.Write(data)
// Convert to a slice for return.
result := buf.Bytes()
// Put returns the buffer to the pool for reuse.
bufPool.Put(buf)
return result
}
Get pulls a buffer from the pool. If the pool has one, you get a used buffer. Reset clears the content but keeps the underlying capacity, so the buffer can grow again without reallocating. You use the buffer, then Put sends it back. The next request grabs it again. No allocation if the pool is warm.
The New function runs only when the pool is empty. This happens at startup or if the garbage collector clears the pool. sync.Pool is designed for temporary objects. The pool can discard objects at any time during GC. Do not use it for stateful objects that must survive. If you need a buffer and the pool is empty, New provides a fallback.
Pools recycle objects. They do not eliminate allocations; they move them.
Escape analysis
The compiler decides where variables live. If a variable lives beyond the function call, it moves to the heap. This process is called escape analysis. Variables that escape to the heap trigger allocations.
Here is a function that forces an escape.
package main
// returnsPointer returns a pointer to a local variable.
func returnsPointer() *int {
x := 42
// x escapes to the heap because the pointer outlives the function.
return &x
}
// returnsValue returns a value directly.
func returnsValue() int {
x := 42
// x stays on the stack because the value is copied to the caller.
return x
}
In returnsPointer, the variable x is local, but the function returns a pointer to it. If x stayed on the stack, the pointer would dangle after the function returns. The compiler detects this and moves x to the heap. You get an allocation.
In returnsValue, the function returns the integer directly. The caller receives a copy. x can stay on the stack. No allocation.
You can see escape analysis in action with the compiler flag. Run go build -gcflags="-m" to print escape decisions. The output shows which variables escape and why.
The compiler moves variables to the heap when they outlive the function. Trust the compiler, but verify with the flag.
Interface boxing and slice headers
Interfaces and slices are powerful, but they hide allocation costs.
An interface value is a pair of pointers: one to type information and one to the data. When you store a concrete value in an interface, the runtime copies the value to the heap so the interface can hold a pointer to it. This is called boxing.
package main
// boxInt puts an int into an interface.
func boxInt(x int) interface{} {
// Putting a concrete type in an interface allocates memory on the heap.
return x
}
If you pass an int to a function expecting interface{}, you trigger an allocation. This happens silently. If you call this function in a tight loop, you generate garbage.
Slices are headers containing a pointer, length, and capacity. Passing a slice is cheap because you copy the header, not the data. However, sub-slicing can prevent garbage collection.
package main
// trimSlice returns a subslice of the first byte.
func trimSlice(data []byte) []byte {
// Returning a subslice keeps the original backing array alive.
return data[:1]
}
If data is a 1 MB array and you return data[:1], the slice points to the first byte of the 1 MB array. The runtime cannot free the 1 MB array because the slice still references it. You have leaked 1 MB of memory. Use append to copy the data or runtime.KeepAlive if you need to control lifetime explicitly.
The convention "accept interfaces, return structs" helps here. If a function returns a struct, the caller can choose to put it in an interface or not. If the function returns an interface, the allocation might have already happened inside the function. Keep interfaces at the boundary, not in the internals.
Interfaces cost allocations. Sub-slices hide memory. Be explicit about ownership.
Allocation helpers: make vs new
Go has two allocation keywords. They do different things.
new(T) returns a pointer to a zeroed value of type T. It allocates memory and returns *T. make(T) initializes the internal structure of slices, maps, and channels. It returns T, not a pointer.
package main
func compareAllocs() {
// new returns a pointer to zeroed memory.
p := new([]byte)
// make initializes the slice header and allocates the backing array.
s := make([]byte, 10)
}
new([]byte) returns a pointer to a nil slice. The slice header is zeroed, so the pointer inside is nil. make([]byte, 10) returns a slice with length 10 and capacity 10, backed by a newly allocated array.
Use make for slices, maps, and channels. Use new only when you need a pointer to a zero value, which is rare. The community rarely uses new. make is the standard for initializing reference types.
Use make for slices and maps. Use new only when you need a pointer to a zero value.
When to use what
Use strings.Builder when constructing strings from multiple parts in a loop or dynamic size. Use sync.Pool when allocating short-lived objects in a high-throughput loop and you can reset the object between uses. Use bytes.Buffer when you need mutable byte sequences and the size is unknown. Use stack allocation by keeping variables local and returning values; the compiler handles this automatically. Use arena or manual memory management when you have bulk allocations that all die at once and you want to free them in one shot. Use plain concatenation when combining a fixed number of small strings; the compiler optimizes this into a single allocation.
Profile before you optimize. Allocations are cheap until they aren't.