Go Under the Hood · Part 9

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.

CodeMeaning to the programWhat to investigate separately
var count intDeclare an integer starting at zeroWhether it needs a memory slot at all
count = 7Replace its current valueThis assignment does not request a separate integer object
other := countCreate another variable with a copied valueWhere either value is represented
p := new(int)Obtain a pointer to a new zero-valued integerWhether its storage escapes to the heap
buf := make([]byte, 16)Create a slice and its backing storageWhere that storage lives in this context
alias := bufCopy the slice valueBoth 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:

BenchmarkB/opallocs/opWhat the result describes
Fresh1,0241New backing storage escapes through kept each iteration
Reuse00One existing buffer is reused inside the measured loop
Local00The 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 caseReasonable starting pointTrade-off to check
Small local calculationOrdinary local valuesDo not add reuse machinery without evidence
Building a known-size batchAllocate suitable capacity onceAvoid retaining excessive spare capacity
Caller retains each resultIndependent storage or an explicit ownership transferReusing the same backing array can corrupt older results
Synchronous scratch workReuse storage while one owner controls itClear stale contents when required and bound retained capacity
Memory grows despite low allocation countsInvestigate what remains reachableAllocation 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

  1. In Experiment 1, append one byte to *ps and assign the result back: *ps = append(*ps, 5). It becomes a usable one-element slice. The pointer alone did not create those elements.
  2. Change Experiment 2’s initial capacity from 2 to 3. The second append now reuses storage: the final line becomes beyond capacity: 99 99 true. Rename the label to match the new behaviour.
  3. In Local, replace checksum = buf[0] with kept = buf. Rerun that benchmark. On the verified toolchain it changes to 1024 B/op and 1 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?

Sources

Back to the journal