Skip to content
gohacks.dev
Basics

Should a Go Function Return a Pointer or a Plain Value

Returning a pointer forces a heap allocation, and returning a value is often cheaper even for a kilobyte struct. Benchmarks, escape analysis and a clear rule.

8 min read
Summarize in AI
Flat illustration of an editor window full of code.

The advice you will hear most often is "return a pointer, it avoids copying". It is wrong enough of the time to deserve a measurement. The copy of a struct is a block move on the stack, which the CPU does quickly. The pointer needs a heap allocation, which costs more and keeps costing after the function returns, because the garbage collector has to find and free it later. This article measures both on one machine, shows what the compiler decides and when, and ends on the rule I follow. It is a companion to pointer or value receiver and to pointers in Go, and part of the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)

What actually differs

When a function returns a struct by value, the result is written into space the caller reserved. When it returns a pointer to a struct that was created inside, that struct has to outlive the call, so it cannot live in the function's stack frame. The compiler's escape analysis sees the & and moves the value to the heap. Allocation on the heap needs a lock-free but not free trip into the allocator, and every allocated object adds to the work of the next garbage collection.

You can ask the compiler what it decided. This program returns the same struct both ways, and the -gcflags=-m flag prints the escape analysis:

main.goGo
package main

import "fmt"

type Point struct{ X, Y int }

//go:noinline
func byValue() Point { return Point{1, 2} }

//go:noinline
func byPointer() *Point { return &Point{1, 2} }

func main() {
	a := byValue()
	b := byPointer()
	fmt.Println(a, *b)
}
Text
# example.test/lab
example.test/lab/main.go:16:13: inlining call to fmt.Println
example.test/lab/main.go:11:34: &Point{...} escapes to heap
example.test/lab/main.go:16:13: ... argument does not escape
example.test/lab/main.go:16:14: a escapes to heap
example.test/lab/main.go:16:17: *b escapes to heap

The output is the compiler's own commentary. The line for &Point{...} says it escapes to heap, which is the allocation, and the line for byValue has no such note. I used //go:noinline so the compiler cannot inline the functions and hide the difference. Without it, a small constructor is usually inlined at the call site, and the pointer need not escape if the caller does not store it anywhere. That inlining is real and common, so the cost below is the worst case, and it is what you pay whenever the result is stored in a field, a global, a slice or a channel.

The measurement

The benchmark returns a 24 byte struct and a struct of about 1 KB, each both ways, and stores the result in a package variable so that it must be kept:

main_test.goGo
package main

import "testing"

type Small struct{ A, B, C int }

type Large struct {
	Data [1024]byte
	N    int
}

var (
	sinkS  Small
	sinkSP *Small
	sinkL  Large
	sinkLP *Large
)

//go:noinline
func newSmall() Small { return Small{1, 2, 3} }

//go:noinline
func newSmallPtr() *Small { return &Small{1, 2, 3} }

//go:noinline
func newLarge() Large { return Large{N: 1} }

//go:noinline
func newLargePtr() *Large { return &Large{N: 1} }

func BenchmarkSmallValue(b *testing.B) {
	for b.Loop() {
		sinkS = newSmall()
	}
}

func BenchmarkSmallPointer(b *testing.B) {
	for b.Loop() {
		sinkSP = newSmallPtr()
	}
}

func BenchmarkLargeValue(b *testing.B) {
	for b.Loop() {
		sinkL = newLarge()
	}
}

func BenchmarkLargePointer(b *testing.B) {
	for b.Loop() {
		sinkLP = newLargePtr()
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkSmallValue-10      	557195002	         2.120 ns/op	       0 B/op	       0 allocs/op
BenchmarkSmallValue-10      	565391557	         2.117 ns/op	       0 B/op	       0 allocs/op
BenchmarkSmallValue-10      	569204830	         2.114 ns/op	       0 B/op	       0 allocs/op
BenchmarkSmallPointer-10    	82227684	        15.11 ns/op	      24 B/op	       1 allocs/op
BenchmarkSmallPointer-10    	81334563	        14.97 ns/op	      24 B/op	       1 allocs/op
BenchmarkSmallPointer-10    	81005821	        14.76 ns/op	      24 B/op	       1 allocs/op
BenchmarkLargeValue-10      	36993933	        33.60 ns/op	       0 B/op	       0 allocs/op
BenchmarkLargeValue-10      	36795237	        33.66 ns/op	       0 B/op	       0 allocs/op
BenchmarkLargeValue-10      	34903802	        32.71 ns/op	       0 B/op	       0 allocs/op
BenchmarkLargePointer-10    	 6533179	       213.5 ns/op	    1152 B/op	       1 allocs/op
BenchmarkLargePointer-10    	 4744866	       256.1 ns/op	    1152 B/op	       1 allocs/op
BenchmarkLargePointer-10    	 4728650	       227.3 ns/op	    1152 B/op	       1 allocs/op
PASS
ok  	example.test/lab	14.803s

Read the columns for each pair. The small struct took about 2 ns and 0 allocations by value, and about 14 ns, 24 bytes and 1 allocation by pointer. That is roughly seven times slower for a result that is only three words. The large struct is the surprising row. Returned by value, it cost about 33 ns with no allocation. Returned by pointer, it cost 200 to 300 ns and allocated 1,152 bytes, which is the 1 KB plus the allocator's size class rounding. So even for a kilobyte of data, copying was several times cheaper than allocating, and the allocation also leaves the garbage collector more to do, which these numbers do not capture.

Caveats belong next to the numbers. This is one machine, three runs, a microbenchmark that does nothing with the value afterwards. A real caller would use the result, and a value that is then passed around by value again pays the copy at every hop, which shifts the balance. The direction is what to take from it. Copying is cheap compared to what people fear, and allocating is expensive compared to what people assume. The crossover where a pointer wins comes when the same large value is handed through many layers, or kept long-term.

When a pointer is the right return

Some types are not values. A *sql.DB, an *http.Server and a *os.File each have identity: there is one connection pool, one listener, one open file, and copying it would be a bug or a lie. These return pointers because two copies would disagree about state. The same applies to a type that contains a sync.Mutex or any resource that must not be copied, and to a type whose methods have pointer receivers, where handing out a value would give callers a copy their methods cannot mutate usefully.

A pointer is also the answer when the caller is expected to modify the result and have others see it, as with a node in a tree or a shared configuration object. And it is the answer when nil has a meaning, such as "no result" for a lookup. That last use is the one I would think hardest about, because it moves a failure to a later line.

Prefer (T, error) or (T, bool) to a nil pointer

A function that may find nothing can return *User and use nil for "not found". The caller must remember to check, and a forgotten check is a nil dereference panic far from the cause. The value form moves the check into the type signature:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type User struct{ Name string }

var users = map[int]User{1: {"ada"}}

var ErrNotFound = errors.New("user not found")

func findPtr(id int) *User {
	if u, ok := users[id]; ok {
		return &u
	}
	return nil
}

func findValue(id int) (User, error) {
	if u, ok := users[id]; ok {
		return u, nil
	}
	return User{}, ErrNotFound
}

func main() {
	if u := findPtr(2); u != nil {
		fmt.Println(u.Name)
	}

	u, err := findValue(2)
	fmt.Printf("%+v %v\n", u, err)

	u, err = findValue(1)
	fmt.Printf("%+v %v\n", u, err)
}
Text
{Name:} user not found
{Name:ada} <nil>

With the pointer version, forgetting the != nil check compiles and panics at run time. With (User, error), forgetting to read the error is possible but loud: linters such as errcheck flag it, and the compiler stops you from using the result without handling the second value. The Go habit is (T, error) for operations that can fail with a reason, and (T, bool) for lookups where "absent" is not an error, as in v, ok := m[k]. The returned value on failure is the type's zero value, which is another reason to design types around a useful zero value.

Constructors

Constructors are where the question comes up most often. NewServer returns *Server because a server has identity and must not be copied. NewPoint should return a Point. A constructor that validates and may fail returns (T, error) or (*T, error) on the same logic. If you write a constructor for a plain data struct and find yourself returning a pointer out of habit, try returning the value and see whether anything about the callers gets worse. Usually nothing does. Constructor functions in Go covers the naming and the options pattern.

My rule

Return a value unless there is a reason from the list above, and write the reason down if it is not obvious. In review I ask two questions about a returned pointer. Does the caller need to mutate or share it? Is nil a result I expect callers to handle? If both answers are no, it should be a value. The rule has a performance side effect that pleases the profile, and the better reason is that values cannot be nil, cannot be mutated through a stray alias and cannot be shared by accident.

Where returning a pointer is still right

Large structs that are built once, kept for a long time and passed around should be pointers, because the cost to avoid is repeated copying at every hand-off rather than the single return. A builder or an accumulator that a function fills and returns, such as a parser's result tree, usually is pointer-shaped because the nodes refer to each other. And an interface value holding a struct will allocate anyway when boxed, so returning a value that is immediately put in an interface gains nothing. When in doubt, benchmark the real code path with -benchmem and look at the allocs/op column, as the testing package documentation describes, and read the escape analysis output once for the function you care about. The compiler's escape analysis notes in the Go FAQ explain why you do not have to decide where memory lives yourself.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.