All Benchmarks

Go Benchmark

Struct Copy vs Pointer

Does passing structs by pointer actually make your code faster?

structpointercopyescape-analysisallocationpass-by-value

"Always pass structs by pointer, copying is slow" is one of the most repeated pieces of Go performance advice — and it is often wrong. This benchmark passes and returns a small 32-byte struct and a large 4 KiB struct both by value and by pointer, plus the constructor case where returning a pointer forces the value to escape to the heap. Copying small structs is essentially free, while returning large structs by pointer trades a stack copy for a heap allocation and future garbage-collector work. All functions are marked //go:noinline so the calls and copies are actually measured instead of optimized away.

linux/amd64AMD Ryzen 9 9950X3D 16-Core Processorbenchmarks/struct-copy-vs-pointer
Compare atCPUs

1 CPU

Fastest

Small Struct By Pointer

0.75 ns/op

Slowest

Return Large By Pointer

405.5 ns/op · 538x slower

32 CPUs

Fastest

Small Struct By Pointer

0.78 ns/op

Slowest

Return Large By Pointer

783.2 ns/op · 1,008x slower

Performance Comparison (lower is better)
CPU:
#1

Small Struct By Pointer #

Fastest

Mutates the same 32-byte struct through a pointer. Saves the tiny copy, but every access goes through an indirection, and sharing mutable state through pointers is exactly what escape analysis and the garbage collector have to be pessimistic about.

CPU Scaling (lower is better)
// SmallStruct is 32 bytes — the size of a typical small domain struct.
type SmallStruct struct {
	A, B, C, D int64
}

//go:noinline
func addSmallByPointer(s *SmallStruct) {
	s.A++
}

func BenchmarkSmallStructByPointer_run(b *testing.B) {
	s := &SmallStruct{}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		addSmallByPointer(s)
	}
	sink = s.A
}
1 CPU
1xfaster(1%)thanLarge Struct By Pointer
99.4xfaster(9840%)thanLarge Struct By Value
538.3xfaster(53729%)thanReturn Large By Pointer
65.8xfaster(6485%)thanReturn Large By Value
1xfaster(3%)thanSmall Struct By Value
32 CPUs
1xfaster(1%)thanLarge Struct By Pointer
97.5xfaster(9648%)thanLarge Struct By Value
1008.2xfaster(100715%)thanReturn Large By Pointer
65.5xfaster(6452%)thanReturn Large By Value
1xfaster(2%)thanSmall Struct By Value
#2

Large Struct By Pointer #

Mutates the 4 KiB struct in place through a pointer. No copies at all, independent of struct size — for genuinely large structs on hot paths this is the clear winner.

CPU Scaling (lower is better)
// LargeStruct is 4 KiB — large enough that copying it dominates the cost.
type LargeStruct struct {
	Data [512]int64
}

//go:noinline
func addLargeByPointer(s *LargeStruct) {
	s.Data[0]++
}

func BenchmarkLargeStructByPointer_run(b *testing.B) {
	s := &LargeStruct{}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		addLargeByPointer(s)
	}
	sink = s.Data[0]
}
1 CPU
98.6xfaster(9763%)thanLarge Struct By Value
534.1xfaster(53313%)thanReturn Large By Pointer
65.3xfaster(6434%)thanReturn Large By Value
1xslower(1%)thanSmall Struct By Pointer
1xfaster(2%)thanSmall Struct By Value
32 CPUs
96.3xfaster(9529%)thanLarge Struct By Value
995.9xfaster(99492%)thanReturn Large By Pointer
64.7xfaster(6373%)thanReturn Large By Value
1xslower(1%)thanSmall Struct By Pointer
#3

Small Struct By Value #

Passes and returns a 32-byte struct by value. The struct fits in a few registers or one cache line, so the "copy" is essentially free — this is why the Go standard library passes small values like time.Time by value.

CPU Scaling (lower is better)
// SmallStruct is 32 bytes — the size of a typical small domain struct.
type SmallStruct struct {
	A, B, C, D int64
}

// Inlining is disabled so the call (and its copies) is not optimized away.
//
//go:noinline
func addSmallByValue(s SmallStruct) SmallStruct {
	s.A++
	return s
}

func BenchmarkSmallStructByValue_run(b *testing.B) {
	var s SmallStruct
	for i := 0; i < b.N; i++ {
		s = addSmallByValue(s)
	}
	sink = s.A
}
1 CPU
1xslower(2%)thanLarge Struct By Pointer
96.8xfaster(9581%)thanLarge Struct By Value
524.3xfaster(52331%)thanReturn Large By Pointer
64.1xfaster(6313%)thanReturn Large By Value
1xslower(3%)thanSmall Struct By Pointer
32 CPUs
95.9xfaster(9494%)thanLarge Struct By Value
992.3xfaster(99128%)thanReturn Large By Pointer
64.5xfaster(6349%)thanReturn Large By Value
1xslower(2%)thanSmall Struct By Pointer
#4

Return Large By Value #

A constructor that returns a 4 KiB struct by value into a caller-owned variable. The result never escapes, so the whole operation happens on the stack with zero heap allocations — the copy is cheaper than a garbage-collected allocation.

CPU Scaling (lower is better)
// LargeStruct is 4 KiB — large enough that copying it dominates the cost.
type LargeStruct struct {
	Data [512]int64
}

//go:noinline
func newLargeByValue(seed int64) LargeStruct {
	var s LargeStruct
	s.Data[0] = seed
	return s
}

func BenchmarkReturnLargeByValue_run(b *testing.B) {
	var s LargeStruct
	for i := 0; i < b.N; i++ {
		// The result is copied into the caller's stack frame — no heap allocation.
		s = newLargeByValue(int64(i))
	}
	sink = s.Data[0]
}
1 CPU
65.3xslower(6434%)thanLarge Struct By Pointer
1.5xfaster(51%)thanLarge Struct By Value
8.2xfaster(718%)thanReturn Large By Pointer
65.8xslower(6485%)thanSmall Struct By Pointer
64.1xslower(6313%)thanSmall Struct By Value
32 CPUs
64.7xslower(6373%)thanLarge Struct By Pointer
1.5xfaster(49%)thanLarge Struct By Value
15.4xfaster(1439%)thanReturn Large By Pointer
65.5xslower(6452%)thanSmall Struct By Pointer
64.5xslower(6349%)thanSmall Struct By Value
#5

Large Struct By Value #

Passes and returns a 4 KiB struct by value, copying it into the callee's frame and back on every call. Here the copy cost is real and grows linearly with struct size — this is the case the "use pointers" advice is actually about.

CPU Scaling (lower is better)
// LargeStruct is 4 KiB — large enough that copying it dominates the cost.
type LargeStruct struct {
	Data [512]int64
}

//go:noinline
func addLargeByValue(s LargeStruct) LargeStruct {
	s.Data[0]++
	return s
}

func BenchmarkLargeStructByValue_run(b *testing.B) {
	var s LargeStruct
	for i := 0; i < b.N; i++ {
		s = addLargeByValue(s)
	}
	sink = s.Data[0]
}
1 CPU
98.6xslower(9763%)thanLarge Struct By Pointer
5.4xfaster(442%)thanReturn Large By Pointer
1.5xslower(51%)thanReturn Large By Value
99.4xslower(9840%)thanSmall Struct By Pointer
96.8xslower(9581%)thanSmall Struct By Value
32 CPUs
96.3xslower(9529%)thanLarge Struct By Pointer
10.3xfaster(934%)thanReturn Large By Pointer
1.5xslower(49%)thanReturn Large By Value
97.5xslower(9648%)thanSmall Struct By Pointer
95.9xslower(9494%)thanSmall Struct By Value
#6

Return Large By Pointer #

Slowest

A constructor that returns *LargeStruct. Escape analysis has to move the local onto the heap, so every call allocates 4 KiB that the garbage collector must track and reclaim. Watch the allocation columns: this is the hidden cost of the "just return a pointer" reflex.

CPU Scaling (lower is better)
// LargeStruct is 4 KiB — large enough that copying it dominates the cost.
type LargeStruct struct {
	Data [512]int64
}

//go:noinline
func newLargeByPointer(seed int64) *LargeStruct {
	var s LargeStruct
	s.Data[0] = seed
	// Returning a pointer to a local makes it escape to the heap.
	return &s
}

func BenchmarkReturnLargeByPointer_run(b *testing.B) {
	var s *LargeStruct
	for i := 0; i < b.N; i++ {
		s = newLargeByPointer(int64(i))
	}
	sink = s.Data[0]
}
1 CPU
534.1xslower(53313%)thanLarge Struct By Pointer
5.4xslower(442%)thanLarge Struct By Value
8.2xslower(718%)thanReturn Large By Value
538.3xslower(53729%)thanSmall Struct By Pointer
524.3xslower(52331%)thanSmall Struct By Value
32 CPUs
995.9xslower(99492%)thanLarge Struct By Pointer
10.3xslower(934%)thanLarge Struct By Value
15.4xslower(1439%)thanReturn Large By Value
1008.2xslower(100715%)thanSmall Struct By Pointer
992.3xslower(99128%)thanSmall Struct By Value

Contributors