What Is Memory Allocation in Go?
Learn how Go allocation differs from assignment and initialisation, when new, make, and append need storage, and what B/op and allocs/op actually measure.
A function creates a slice on every call, yet its benchmark reports 0 allocs/op. Another benchmark also reports zero, but keeps a buffer in memory throughout the run. Neither result means the program uses no memory.
Allocation means arranging storage for data. In Go performance discussions, “an allocation” usually means a heap allocation measured by the runtime. Source-level creation and measured heap allocation are different questions.
This is Part 9 of Go Under the Hood, beginning the memory phase after Closures, Defer, and Panic. You should understand variables, pointers, and slices. Examples use Go 1.27, verified with Go 1.27.1 and the standard library. Exact benchmark results describe the tested toolchain and workload, not a language guarantee.
Allocation, initialisation, and assignment
Imagine a variable as a place where a value can live. Allocation provides storage; initialisation supplies its starting value; assignment replaces a value already held by a variable. One statement can involve several of these activities.
| Code | Meaning to the program | What to investigate separately |
|---|---|---|
var count int | Declare an integer starting at zero | Whether it needs a memory slot at all |
count = 7 | Replace its current value | This assignment does not request a separate integer object |
other := count | Create another variable with a copied value | Where either value is represented |
p := new(int) | Obtain a pointer to a new zero-valued integer | Whether its storage escapes to the heap |
buf := make([]byte, 16) | Create a slice and its backing storage | Where that storage lives in this context |
alias := buf | Copy the slice value | Both slices still reach the same elements |
An assignment can also evaluate an allocating expression: buf = make([]byte, n) is still an assignment, but its right-hand side creates storage. Counting assignment statements is therefore not an allocation measurement.
Go initialises variables without explicit initial values to their zero values. The forms of new and make specify values and types, not a promise that each source expression becomes a heap allocation. Go specification: initial values, allocation, and making slices, maps, and channels
Experiment 1: new and make solve different problems
Create a module using a Go 1.27 toolchain:
go version
mkdir allocation-demo
cd allocation-demo
go mod init example.com/allocation-demo
go mod edit -go=1.27.0
Save this as main.go:
package main
import "fmt"
func main() {
var count int
other := count
count = 7
fmt.Println("values:", count, other)
p := new(int)
fmt.Println("new integer:", *p)
*p = 9
fmt.Println("updated:", *p)
ps := new([]byte)
buf := make([]byte, 2, 4)
fmt.Println("new slice:", *ps == nil, len(*ps), cap(*ps))
fmt.Println("made slice:", buf == nil, len(buf), cap(buf))
alias := buf
alias[0] = 8
fmt.Println("shared elements:", buf, alias)
}
Run:
go run .
go vet .
Output:
values: 7 0
new integer: 0
updated: 9
new slice: true 0 0
made slice: false 2 4
shared elements: [8 0] [8 0]
new([]byte) gives you a pointer to a slice variable whose value is nil. It does not create a backing array of usable elements. make([]byte, 2, 4) gives you a slice with two accessible elements and capacity for four. The length controls indexing: buf[2] = 1 would panic even though capacity is four.
Choose make when you need an initialised slice, map, or channel. Choose a pointer when the API needs an addressable value with shared identity. A pointer to a slice is usually unnecessary just to modify existing elements; return an updated slice when an operation changes its length. The aliasing here follows Values, Pointers, and Copies.
This example uses the type form new(T). Go 1.27 also supports new(expression), which initialises the pointed-to variable from that expression. Neither form is a command to place the value on the heap. Go specification: new
Experiment 2: append may reuse existing storage
Replace main.go with:
package main
import "fmt"
func main() {
base := make([]int, 1, 2)
base[0] = 10
within := append(base, 20)
within[0] = 11
fmt.Println("within capacity:", base[0], &base[0] == &within[0])
beyond := append(within, 30)
beyond[0] = 99
fmt.Println("beyond capacity:", within[0], beyond[0],
&within[0] == &beyond[0])
}
Output:
within capacity: 11 true
beyond capacity: 11 99 false
The first append fits. The second needs a different backing array, so changing its first element no longer changes within. This demonstrates storage reuse and separation, not stack-versus-heap placement. Go specification: append
For a batch with a reliable size estimate, preallocating capacity can avoid repeated growth. An oversized estimate also reserves more space than the batch needs. Choose a realistic bound rather than the largest conceivable request. Arrays and Slices Under the Hood explores the ownership consequences in more detail.
Stack and heap are implementation choices
With the standard Go toolchain, temporary storage can live on a goroutine’s stack. Heap storage accommodates objects whose lifetime or other requirements prevent that placement. The compiler may also represent values in registers or eliminate unnecessary storage while preserving behaviour.
Taking an address does not automatically force heap placement, and local declarations do not automatically guarantee stack placement. Escape analysis considers how values are used; inlining and compiler changes can affect the result. Go FAQ: stack or heap
The practical distinction is lifetime. A callback or returned pointer may need data after its creating function has returned; Go must keep that data valid. You do not manually free ordinary Go objects. The standard runtime garbage collector reclaims unreachable heap storage, while reachable objects remain live. Go garbage collector guide
Start by choosing correct ownership and lifetimes. Use measurements to decide whether placement deserves attention. Rewriting clear value semantics into shared pointers can add complexity without fixing the expensive part of a workload.
Experiment 3: read allocation metrics in context
Keep main.go and add allocation_test.go alongside it:
package main
import "testing"
var kept []byte
var checksum byte
func BenchmarkFresh(b *testing.B) {
for b.Loop() {
buf := make([]byte, 1024)
buf[0] = 7
kept = buf
}
}
func BenchmarkReuse(b *testing.B) {
buf := make([]byte, 1024)
kept = buf
for b.Loop() {
buf[0] = 7
kept = buf
}
}
func BenchmarkLocal(b *testing.B) {
for b.Loop() {
buf := make([]byte, 1024)
buf[0] = 7
checksum = buf[0]
}
}
Run five samples:
go test -run '^$' -bench . -benchmem -benchtime=100ms -count=5
The workload is deliberately small: create or reuse 1,024 bytes, write one byte, and expose either the slice or the byte through a package variable. It does not simulate a request handler. There are no external dependencies.
Observed on Go 1.27.1, macOS 15.3, Apple M4, darwin/arm64, with the default benchmark setting reporting -10. Every sample produced these allocation metrics:
| Benchmark | B/op | allocs/op | What the result describes |
|---|---|---|---|
| Fresh | 1,024 | 1 | New backing storage escapes through kept each iteration |
| Reuse | 0 | 0 | One existing buffer is reused inside the measured loop |
| Local | 0 | 0 | The local creation produces no measured heap allocation |
B/op reports allocated heap bytes divided by measured operations. allocs/op reports the corresponding allocation count per operation. These are integer averages; occasional allocations can be rounded down in the displayed result. They do not describe peak memory or how much remains live. testing.BenchmarkResult
b.Loop excludes setup before its first call from the measurement. Consequently, Reuse still has a buffer, but acquiring it is outside the measured loop. Fresh retains only the latest slice through kept; its cumulative allocation count does not imply that every previous buffer stays reachable. testing.B.Loop
Local contains make inside the loop and still reports zero. That result alone does not prove a particular stack layout: compiler diagnostics or generated code are needed to distinguish placement and optimisation. It does demonstrate why counting make calls cannot predict heap-allocation counts.
The three cases have different lifetime requirements. Reuse overwrites shared storage; Fresh supplies new storage; Local exposes only a byte. Their timings are not an interchangeable implementation comparison, so this table focuses on the allocation question.
Why it matters, and when to change something
Allocation rate and retained memory create different problems. Repeated short-lived allocations can add allocator and garbage-collector work even when live memory remains small. A reused buffer can reduce churn but keep a large amount of memory reachable. Go garbage collector guide: costs
| Use case | Reasonable starting point | Trade-off to check |
|---|---|---|
| Small local calculation | Ordinary local values | Do not add reuse machinery without evidence |
| Building a known-size batch | Allocate suitable capacity once | Avoid retaining excessive spare capacity |
| Caller retains each result | Independent storage or an explicit ownership transfer | Reusing the same backing array can corrupt older results |
| Synchronous scratch work | Reuse storage while one owner controls it | Clear stale contents when required and bound retained capacity |
| Memory grows despite low allocation counts | Investigate what remains reachable | Allocation counts alone cannot identify retention |
For example, a parser that returns a view into a reused input buffer must document how long the view remains valid. If callers keep results, copying may be the correct cost. A zero-allocation result is not worth an invalid ownership contract.
Measure representative work before introducing a pool or changing an API. Check correctness, allocated bytes, retained memory, and application latency separately. This introductory benchmark answers only how much heap allocation occurs inside these loops.
Exercise: predict what changes
- In Experiment 1, append one byte to
*psand assign the result back:*ps = append(*ps, 5). It becomes a usable one-element slice. The pointer alone did not create those elements. - Change Experiment 2’s initial capacity from
2to3. The second append now reuses storage: the final line becomesbeyond capacity: 99 99 true. Rename the label to match the new behaviour. - In Local, replace
checksum = buf[0]withkept = buf. Rerun that benchmark. On the verified toolchain it changes to1024 B/opand1 allocs/op, because the whole slice is exposed beyond the iteration.
The next planned part, Stack vs Heap in Go, examines placement and lifetimes more closely. For now, ask two separate questions: what storage does the program need, and which heap allocations does this measurement actually include?