Everyday Allocations in Go: Slices, Maps, Strings, Values, and Pointers
How I think about allocation behavior in ordinary production code
“Plan to throw one away; you will, anyhow.”
Frederick P. Brooks Jr., The Mythical Man-Month: Essays on Software Engineering, Addison-Wesley, 1975, Chapter 11.
This is the third article in the series. The first article explained stack, heap, and escape analysis. The second article connected heap allocation to garbage collection, allocation pressure, and runtime trade-offs. Now we can move into everyday Go code: the ordinary constructs that create, retain, copy, and share memory.
This article is analytical rather than hands-on. I will show small code fragments, but the goal is not to prescribe micro-optimizations. The goal is to build a sharper eye for when normal Go patterns allocate, when that matters, and how data structure semantics affect lifetime.
The official Go specification is the primary language reference for built-ins such as make, new, append, slices, maps, and strings. The current specification is available at https://go.dev/ref/spec, and the sections on making slices/maps/channels, appending/copying slices, and string types are especially relevant: https://go.dev/ref/spec#Making_slices_maps_and_channels, https://go.dev/ref/spec#Appending_and_copying_slices, and https://go.dev/ref/spec#String_types.
With that baseline, we can look at the code patterns that usually deserve attention in hot paths.
Allocation is common in normal Go code
Go code allocates memory through explicit built-ins such as make and new, but also through operations that are less visually obvious. append may allocate when a slice needs a larger backing array. Map growth may allocate as the table expands. String construction can allocate. Formatting with fmt.Sprintf often allocates. JSON encoding can allocate. Interface conversions, reflection, closures, and goroutines can all influence allocation behavior depending on code shape.
None of these tools are bad. In fact, they are part of writing clear Go. The mistake is to treat allocation as surprising only when new appears. The more useful habit is to ask whether a value is created, whether a backing store grows, whether data is copied, and whether a reference keeps something alive longer than expected.
That habit becomes especially important with slices, because a slice is small as a value but can retain a large backing array.
Slices are descriptors over arrays
A slice value describes a segment of an underlying array. Conceptually, it contains a pointer to array storage, a length, and a capacity. The Go specification defines slice expressions and describes how capacity is derived from the underlying array: https://go.dev/ref/spec#Slice_expressions.
This is why copying a slice value is cheap but not necessarily independent. Two slice values can point to the same underlying array. Mutating one can affect the other if they overlap. Appending to one may reuse the same backing array or allocate a new one if capacity is exceeded.
The subtle production issue is retention. Consider this function:
func firstBytes(data []byte) []byte {
return data[:10]
}The returned slice has length 10, but it can keep the entire backing array of data alive. If data came from a 20 MB payload, a 10-byte result may retain 20 MB. The garbage collector sees the backing array as reachable because the returned slice still points into it.
When that matters, the fix is to copy the small data into new storage with the intended lifetime:
func firstBytesCopy(data []byte) []byte {
out := make([]byte, 10)
copy(out, data[:10])
return out
}This intentionally allocates a small new backing array to release a large old one. It is a perfect example of why “fewer allocations” is not always the right goal. Sometimes the right allocation reduces retained memory.
Once slice retention is clear, map growth becomes easier to reason about.
Maps allocate for buckets and growth
A Go map is a hash table managed by the runtime. The language specification defines map types and the make(map[K]V, size) form, where the optional capacity hint can reserve initial space: https://go.dev/ref/spec#Map_types and https://go.dev/ref/spec#Making_slices_maps_and_channels.
The capacity hint is not a precise allocation contract, but it communicates expected scale. In hot paths or bulk loading code, creating a map with a reasonable size hint can reduce growth work. For example:
func indexUsers(users []User) map[string]User {
byID := make(map[string]User, len(users))
for _, user := range users {
byID[user.ID] = user
}
return byID
}This is not a universal rule that every map needs a size hint. For small maps, cold paths, or unknown cardinality, adding a hint may not matter. A production-oriented recommendation is conditional: when approximate cardinality is already known and the code is allocation-sensitive, size the map intentionally. That recommendation is grounded in the language’s documented capacity hint and should still be verified with benchmarks when performance is the motivation.
Maps can also retain memory by design. A map that only grows is a cache without an eviction policy. The garbage collector cannot collect values that are still referenced by map entries. That moves us from allocation into lifecycle management, which becomes central in the fourth article.
Get Cássius Paim’s stories in your inbox
Join Medium for free to get updates from this writer.
Before that, strings deserve their own treatment because their immutability changes how construction works.
Strings are immutable, so construction strategy matters
The Go specification defines strings as immutable sequences of bytes: https://go.dev/ref/spec#String_types. Immutability makes strings safe and simple to reason about, but repeated construction can allocate. A loop that repeatedly concatenates strings may create intermediate strings, especially when the compiler cannot optimize the pattern into a single allocation.
In hot paths, strings.Builder is the standard library tool designed to build strings efficiently. Its documentation states that a Builder is used to efficiently build a string using write methods, and it provides Grow to reserve capacity: https://pkg.go.dev/strings#Builder.
A small example makes the intent visible:
func joinIDs(ids []string) string {
var b strings.Builder
for i, id := range ids {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(id)
}
return b.String()
}This does not mean every + should become a builder. Simple concatenation is readable and often optimized well enough. The builder becomes relevant when repeated construction is measured in a hot path or when expected output size is known and Grow can reduce reallocations.
The same reasoning applies to bytes.Buffer for byte-oriented construction, documented at https://pkg.go.dev/bytes#Buffer. Pick the tool that matches the data model: strings for text, bytes for byte streams.
Interfaces, reflection, closures, and goroutines can hide allocation behavior
Some allocation sources are less obvious because they are tied to abstraction. Passing concrete values through interfaces may require boxing depending on the value and usage. Reflection can allocate because it manipulates dynamic representations. Closures can allocate captured state when that state outlives the current frame. Goroutines can force values to survive longer than the launching function.
The important thing is not to fear these features. Interfaces are central to Go design. Reflection powers serialization, testing helpers, and frameworks. Closures are expressive. Goroutines are the language’s concurrency model. The production rule is narrower: when one of these appears in a hot path, check whether it is contributing to allocation pressure before rewriting it.
That check should combine escape analysis and benchmarks. Escape analysis can show why a value moved to the heap. Benchmarks with -benchmem can show whether the path allocates per operation. Profiles can show whether those allocations matter in aggregate. The official diagnostics page provides the map of Go tools for that workflow: https://go.dev/doc/diagnostics.
With abstractions covered, we can address one of the most persistent Go performance myths.
Values versus pointers is a semantic decision first
Passing a struct by value copies it. Passing a pointer copies the pointer and permits mutation of the pointed-to object. For small immutable values, copying can be cheap and clear. For large structs, pointer passing can avoid copying but may introduce indirection, aliasing, and possible heap lifetime.
Consider a small value type:
type Point struct {
X, Y int
}func Move(p Point) Point {
p.X++
return p
}This function has value semantics. It does not mutate the caller’s value. The code is easy to reason about, and for a tiny struct the copy is usually not the bottleneck.
Now compare the pointer version:
func MoveInPlace(p *Point) {
p.X++
}This function communicates mutation. That may be the correct API when in-place update is intended. It is not automatically faster. If the pointer causes a value to escape, or if pointer-rich structures reduce locality, performance may get worse.
A safer recommendation is: choose value or pointer based on semantics first, then measure performance where it matters. This avoids the false rule that “pointer is faster” and keeps API design aligned with ownership and mutation.
Conclusion
Everyday Go code allocates in more places than new. Slices can retain large arrays. Maps grow and retain entries. Strings are immutable, so construction strategy matters in hot paths. Interfaces, reflection, closures, and goroutines can influence lifetime. Values and pointers encode semantics before performance.
The next article takes this memory model into concurrency and leaks. We will look at goroutine stacks, blocked goroutines, channels, timers, tickers, contexts, unbounded maps, and retained references in garbage-collected systems.
💬 Join the Discussion
Which everyday Go allocation surprised you most in production: slices retaining payloads, maps growing without bounds, string construction, reflection, interfaces, or pointer-heavy APIs? I would like to hear examples where the measured result contradicted the team’s intuition.
Originally published on Medium by Cassius Paim, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin
Tags
#Go, #Slices, #Maps, #Strings, #Pointers, #Performance Engineering





