Pointer or Value Receiver in Go and How to Choose One
Choose a pointer receiver when a method mutates, the type is large or holds a mutex, and a value receiver for small immutable types. Benchmarks included.

Every Go developer settles the receiver question the same way, on instinct, and then meets a case where the instinct is wrong. The choice affects three things at once: whether a method can change the value, what it costs to call, and which interfaces the type satisfies. I will give the rule I use, then show the evidence for each part, including a benchmark, so the rule is not just a preference. The type being discussed is usually a struct, and the examples assume you have met pointers. This is part of the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)
The rule I use
A pointer receiver unless the type is a small, immutable value. If the method changes any field, use a pointer. If the struct is large, use a pointer. If it holds a sync.Mutex, a bytes.Buffer or anything else that must not be copied, use a pointer. If the type has even one pointer-receiver method, give all its methods pointer receivers, so the method set is the same everywhere. Small values that behave like numbers, a Point, a Money, a Duration, a time.Time, take value receivers, because copying three words is cheaper than chasing a pointer and the type reads as a value.
That sentence has three conditions and one default, and the sections below test each one.
Mutation: the receiver you asked for
A value receiver works on a copy of the struct. A setter written that way compiles and does nothing:
package main
import "fmt"
type Counter struct{ n int }
func (c Counter) IncrementValue() { c.n++ }
func (c *Counter) IncrementPointer() { c.n++ }
func main() {
var c Counter
c.IncrementValue()
fmt.Println(c.n)
c.IncrementPointer()
fmt.Println(c.n)
p := &c
p.IncrementValue()
p.IncrementPointer()
fmt.Println(c.n)
}0
1
2The first call leaves n at zero, because the method incremented its own copy. The pointer version works. The last lines show that the call syntax is forgiving in both directions: c.IncrementPointer() on an addressable variable is rewritten to (&c).IncrementPointer(), and p.IncrementValue() dereferences the pointer for you. The compiler lets you call either form on either, so the receiver kind is not visible at the call site. That is why a method that "does not work" is hard to spot by reading the caller, and why the first thing to check is the receiver.
Cost: when copying shows up
For small structs the cost is zero, in the sense that I could not measure it. For large structs the copy is the cost. I benchmarked a three-field struct (24 bytes) and a struct of about 1.1 KB, calling the same trivial method through each receiver kind. The last two benchmarks stop the compiler from inlining the call, which would otherwise let it skip the copy:
package main
import "testing"
type Small struct{ A, B, C int }
func (s Small) SumValue() int { return s.A + s.B + s.C }
func (s *Small) SumPointer() int { return s.A + s.B + s.C }
type Large struct {
Header [16]int
Body [1024]byte
N int
}
func (l Large) SumValue() int { return l.Header[0] + int(l.Body[0]) + l.N }
func (l *Large) SumPointer() int { return l.Header[0] + int(l.Body[0]) + l.N }
var sink int
func BenchmarkSmallValue(b *testing.B) {
s := Small{1, 2, 3}
for b.Loop() {
sink += s.SumValue()
}
}
func BenchmarkSmallPointer(b *testing.B) {
s := &Small{1, 2, 3}
for b.Loop() {
sink += s.SumPointer()
}
}
func BenchmarkLargeValue(b *testing.B) {
l := Large{N: 3}
for b.Loop() {
sink += l.SumValue()
}
}
func BenchmarkLargePointer(b *testing.B) {
l := &Large{N: 3}
for b.Loop() {
sink += l.SumPointer()
}
}
//go:noinline
func callValue(l Large) int { return l.SumValue() }
//go:noinline
func callPointer(l *Large) int { return l.SumPointer() }
func BenchmarkLargeValueNoInline(b *testing.B) {
l := Large{N: 3}
for b.Loop() {
sink += callValue(l)
}
}
func BenchmarkLargePointerNoInline(b *testing.B) {
l := &Large{N: 3}
for b.Loop() {
sink += callPointer(l)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkSmallValue-10 542240640 2.140 ns/op 0 B/op 0 allocs/op
BenchmarkSmallValue-10 566292390 2.160 ns/op 0 B/op 0 allocs/op
BenchmarkSmallValue-10 557655693 2.146 ns/op 0 B/op 0 allocs/op
BenchmarkSmallPointer-10 560262342 2.136 ns/op 0 B/op 0 allocs/op
BenchmarkSmallPointer-10 559020535 2.137 ns/op 0 B/op 0 allocs/op
BenchmarkSmallPointer-10 563695098 2.132 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValue-10 37074798 36.06 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValue-10 38093473 31.44 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValue-10 38333758 31.29 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointer-10 553709750 2.173 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointer-10 557294844 2.170 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointer-10 556145066 2.157 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValueNoInline-10 24205626 49.27 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValueNoInline-10 24186964 49.31 ns/op 0 B/op 0 allocs/op
BenchmarkLargeValueNoInline-10 24507508 49.19 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointerNoInline-10 557555936 2.161 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointerNoInline-10 558136614 2.171 ns/op 0 B/op 0 allocs/op
BenchmarkLargePointerNoInline-10 546586640 2.174 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 21.978sThe small struct cost about 2 ns whichever way the method was written. The large one cost about 31 ns as a value receiver, and about 49 ns when inlining was prevented, against about 2 ns through a pointer. That is a factor of fifteen to twenty-five on a call that does almost nothing, so for a type that big the copy dominates. b.Loop() needs Go 1.24 and keeps the compiler from deleting the work. These are single-machine numbers on an Apple M1 Max, three runs each, from a microbenchmark that touches one cache line of data. Real methods do more work, so the share of time spent copying is smaller than it looks here. The finding that holds up is the shape: copying does not matter below a few words, and it starts to matter at hundreds of bytes.
Where the threshold sits is for you to measure. A frequent rule of thumb is "a few words", and the standard library's time.Time is a three-word value that takes value receivers throughout. I would not move a struct to a pointer receiver for performance alone below about 100 bytes without a profile showing a reason.
Consistency and the method set
The compiler allows a type to mix receiver kinds, and the result is a type whose method set depends on how you hold it. The method set of T includes only value-receiver methods. The method set of *T includes both. So a type with a pointer-receiver String method does not satisfy fmt.Stringer as a value:
package main
import "fmt"
type Temp float64
func (t *Temp) String() string { return fmt.Sprintf("%.1f C", float64(*t)) }
func show(s fmt.Stringer) { fmt.Println(s.String()) }
func main() {
t := Temp(21.5)
show(&t)
show(t)
}# example.test/lab
./main.go:14:7: cannot use t (variable of float64 type Temp) as fmt.Stringer value in argument to show: Temp does not implement fmt.Stringer (method String has pointer receiver)The first call compiles with &t. The second fails with a message saying Temp does not implement fmt.Stringer because the String method has a pointer receiver. The error is accurate and it is often a surprise, because a bare t.String() call would have worked. Interface satisfaction is a property of the method set, and calls on addressable variables hide it. If a type mixes receiver kinds, you have to remember which of its methods are visible in each form. Choosing one kind removes the question, and it is the stronger reason to be consistent than style.
There is one more place this bites: values you cannot take the address of. A map element, a function's return value and a value inside an interface are all non-addressable, so a pointer-receiver method cannot be called on them directly. m["k"].Increment() is a compile error when Increment has a pointer receiver. The same sentence applies to storing the value in an interface and expecting to call it later.
Types that must not be copied
A struct that contains a sync.Mutex, a sync.WaitGroup, or a strings.Builder has state that breaks on copy. Copying a locked mutex copies the locked state, and the copy is locked forever. A value receiver does exactly that on every call. The toolchain notices:
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.Mutex
items map[string]int
}
func (c Cache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.items)
}
func main() {
c := Cache{items: map[string]int{"a": 1}}
fmt.Println(c.Len())
}# example.test/lab
# [example.test/lab]
./main.go:13:9: Len passes lock by value: example.test/lab.Cache contains sync.MutexThe program compiles, and go vet reports that the method passes a lock by value. The first call works because the copy of an unlocked mutex is unlocked. The failure arrives under concurrency, when one goroutine holds the lock and another copies the struct, which is a bug that appears once a week in production and never in a test. The copylocks analyzer in vet exists because of this. Make the receiver *Cache, and keep the struct out of value positions, including range over a slice of such structs.
Reading the choice at a glance
| Situation | Receiver | Why |
|---|---|---|
| The method assigns to a field | Pointer | A copy would swallow the change |
| Struct over roughly 100 bytes | Pointer | Copy cost showed up above, about 31 ns against 2 ns |
| Contains a mutex, buffer or builder | Pointer | Copying breaks the state |
| Small value type such as a point or an amount | Value | Cheap to copy and reads as a value |
| Needs to work with a nil receiver | Pointer | Only a pointer can be nil |
Map or slice type, such as type Set map[string]bool | Value | The header or reference is already small |
| Satisfies an interface through a non-addressable value | Value | The method set of T has to include it |
So which one
If you take only one thing from this: default to the pointer receiver for structs, and use a value receiver deliberately for small immutable ones. The pointer default costs a nil check in the rare case a nil can arrive, and it removes the whole group of bugs where state is copied by accident. The case for value receivers is real and narrower than it is usually presented: immutable values with a handful of fields, where you want users to treat the type like a number. The go.dev FAQ and the Go code review comments give the same advice in longer form, and the spec's section on method sets defines the rule behind the interface error.
When the other choice wins
Choose value receivers for a type you want to be safe to share freely between goroutines without locking. An immutable value can be copied into each of them, and the copy cost buys you the absence of a data race. Also choose them for types you use as map keys or compare with ==, since pointers compare by address and values compare by content. And when you inherit a type whose methods already use value receivers, such as a type from the standard library, follow it in your wrappers instead of mixing. For the places where a pointer is the problem, not the solution, such as escape and heap allocation, see stack versus heap in Go.


