Pointers vs Values Trade-offs
Understand when to use pointers or values in Go, weighing performance, safety, and code clarity with practical examples and decision guidelines.
Go forces an explicit choice every time you pass something to a function, assign a variable, or define a method receiver: do you use a value or a pointer to that value? The decision shapes how your data flows, who can change it, and how the compiler manages memory. There is no single answer, but there are reliable patterns that lead to code that is both correct and easy to reason about.
The Core Tradeoff
Go passes everything by value. Even when you hand a function a pointer, you are passing a copy of that pointer — a new copy of the memory address. Because the copy still points at the same location, the function can reach through it and modify the original data. When you pass the value directly, the function works on its own private copy. Nothing the function does to that copy affects the caller.
That is the whole tradeoff: values give you isolation and safety; pointers give you shared access and the ability to mutate. The trick is knowing which one serves the code you are writing right now.
Receivers vs Parameters:
The same logic applies to method receivers and function parameters. The rules in this document cover both equally. If a method needs to change the struct it is called on, it must have a pointer receiver. Otherwise, a value receiver is often the simpler, safer default.
When Values Are the Better Choice
Values are not the “slower” option you fall back to when you don't need pointers. In many situations they are the clearer, faster, and more idiomatic choice. The standard library treats certain types as primitive data values — things like time.Time, image.Point, or net.IP — and passes them by value everywhere.
Small, Immutable-Looking Data
Built-in types like int, float64, string, and bool should almost never be shared via a pointer. They are tiny, they copy cheaply, and turning them into pointers adds indirection for no benefit. The same applies to small structs that carry a few fields and act like simple values.
type Point struct {
X, Y float64
}
// Value semantics: the caller's point is not touched.
func Distance(a, b Point) float64 {
dx := a.X - b.X
dy := a.Y - b.Y
return math.Sqrt(dx*dx + dy*dy)
}
This function receives copies of two points. It cannot accidentally change the caller's data, and it doesn't force either point to escape to the heap. The code is self-contained and trivially testable.
Safe by Design:
When a function uses only value parameters and returns a value, you never have to worry about aliasing bugs — the caller's data stays untouched no matter what happens inside the function.
Factory Functions That Return Values
Idiomatic Go constructors often return a concrete value, not a pointer, especially when the type is intended to behave like a primitive.
type Config struct {
Host string
Port int
Timeout time.Duration
}
func DefaultConfig() Config {
return Config{
Port: 8080,
Timeout: 30 * time.Second,
}
}
The caller gets a complete, ready-to-use Config value. They can copy it, pass it around, and know that no one else holds a reference that could change it underneath them. This pattern appears throughout the standard library: time.Now() returns a time.Time, not a *time.Time.
When Pointers Are the Right Tool
Pointers solve problems that values cannot. The three main reasons you reach for a pointer are: you need to change the original, you want to avoid copying something large, or you need to express “this might not exist.”
Mutating the Caller's Data
If a function must modify what the caller passed, only a pointer will work.
type BankAccount struct {
balance float64
}
func (a *BankAccount) Deposit(amount float64) {
a.balance += amount
}
Without the pointer receiver, Deposit would modify a copy of the BankAccount and the real balance would never change. This is the most straightforward reason to use a pointer — the function's job is to update state.
Value Receiver Trap:
A method with a value receiver that tries to update a field will compile, run, and silently do nothing. The change is applied to a local copy that disappears when the method returns. If you see a setter method that doesn't use a pointer receiver, it is almost certainly a bug.
Large Structs
When a struct holds many fields or large slices of data, copying it every time you pass it around can waste both CPU and memory. A pointer to a large struct is a single machine word (8 bytes on 64-bit systems), while the struct itself might be hundreds of bytes.
type AnalysisReport struct {
ID string
RawData []byte // possibly megabytes
Summaries []SegmentResult
Metadata map[string]any
}
A function that inspects this report — say, to validate its structure — has no reason to copy the whole thing. Accepting a *AnalysisReport avoids the copy and signals that the function expects to work with the caller's existing data.
Representing Optionality
Go does not have optional types. A string cannot be nil; it defaults to "". If you need to distinguish between “the field was not provided” and “the field was provided and is empty,” a pointer fills the gap.
type UpdateUserRequest struct {
Name *string `json:"name"`
Age *int `json:"age"`
IsActive *bool `json:"is_active"`
}
Now you can check whether req.Name is nil (the field was absent in the JSON body) or points to an empty string "" (the user sent an empty name). This pattern is standard in APIs that support partial updates.
Consistent Receiver Types
If any method on a type needs a pointer receiver — for example, because it mutates state or guards a mutex — then all exported methods on that type should use pointer receivers. Mixing value and pointer receivers on the same type creates a confusing API where some methods operate on a copy and others on the original, often leading to bugs.
The Mix-and-Match Pitfall:
A type with both value and pointer receivers will surprise developers. Even if the value-receiver method does not need mutation, the inconsistency makes callers second-guess every call. Pick one receiver type for the entire type. If in doubt, use a pointer receiver.
How the Compiler Decides: Stack, Heap, and Escape Analysis
Performance discussions around pointers often get tangled in assumptions about “pointer = heap = slow” and “value = copy = slow.” Reality is more nuanced, and the Go compiler's escape analysis is the piece that ties it together.
When a function creates a variable that it does not share outside itself — it returns a value, not a pointer, and doesn't store it in a global — the compiler can place that variable on the stack. Stack allocation is effectively free: the memory is reclaimed when the function returns by simply moving the stack pointer.
When the function returns a pointer to a local variable, that variable must survive the function's return. The compiler sees that the variable “escapes” and places it on the heap. Heap allocation is slower, and the garbage collector must later track and reclaim the memory.
A Concrete Comparison
Here is the benchmark from the Boot.dev article that isolates this effect with a moderately sized struct:
type data struct {
a, b, c, d, e, f, g, h, i, j int64
}
func newData(i int) data {
return data{
int64(i), int64(i + 1), int64(i + 2), int64(i + 3),
int64(i + 4), int64(i + 5), int64(i + 6), int64(i + 7),
int64(i + 8), int64(i + 9),
}
}
func newDataPtr(i int) *data {
d := &data{
int64(i), int64(i + 1), int64(i + 2), int64(i + 3),
int64(i + 4), int64(i + 5), int64(i + 6), int64(i + 7),
int64(i + 8), int64(i + 9),
}
return d
}
// BenchmarkProcessValue-12 273343356 4.236 ns/op 0 B/op 0 allocs/op
// BenchmarkProcessPointer-12 61566219 17.72 ns/op 80 B/op 1 allocs/op
The value version is faster and allocates nothing. The 80-byte struct is copied, but the copy stays on the stack and costs a handful of nanoseconds. The pointer version forces a heap allocation every time, which adds allocation overhead and eventual GC work.
This does not mean values are always faster. When the struct grows large — thousands of bytes — the copying cost eventually outweighs the heap allocation cost. The point is that for small to medium structs, values often win because the stack is extremely fast, even with copying.
Let the Compiler Tell You:
You can see exactly what the compiler decides with go build -gcflags="-m". Look for lines like moved to heap — that tells you a variable escaped. Use this to verify your assumptions before micro-optimizing.
The Danger of Nil Pointer Dereference
A pointer in Go can be nil. Dereferencing a nil pointer — accessing a field or calling a method on it — causes a runtime panic that crashes the program. This is one of the most common bugs in Go code that uses pointers aggressively.
type UserService struct {
Cache *Cache
}
func (s *UserService) Get(id string) (*User, error) {
user, err := s.Cache.Lookup(id) // panic if Cache is nil
if err != nil {
return nil, err
}
return user, nil
}
If s.Cache was never set — perhaps the UserService was constructed without it — the program panics at the method call.
The protection is straightforward: check for nil before accessing a pointer, or design your types so that nil is never a valid state. Some packages initialize all pointer fields in their constructor and document that they are never nil; others use the zero value of a struct (which is always a valid, empty struct) instead of a pointer.
Nil Panics Kill Goroutines:
A nil pointer dereference inside a goroutine that has no recovery will bring down the entire process. Always validate pointers that originate outside the current function — parameters, struct fields, return values — before you reach through them.
A contrasting design uses a value instead of a pointer for the optional field, with an explicit Valid flag or a sentinel value. This moves the failure from a panic at access time to a deliberate check you write, making the code safer and more intentional.
Reference Types: Slices, Maps, and Channels
Go's slices, maps, and channels are often called “reference types” because they carry internal pointers to underlying data. When you pass a slice to a function, you copy a small header (pointer, length, capacity) — not the elements. The function can modify the elements through that copy.
func fillValues(data map[string]int) {
data["alpha"] = 1
data["beta"] = 2
}
func main() {
m := map[string]int{}
fillValues(m)
fmt.Println(m) // map[alpha:1 beta:2]
}
You do not need a pointer to a map to modify its contents. The same holds for slices (you can change existing elements) and channels (you can send and receive). Passing a *[]int or *map[string]int is almost never necessary and is usually a sign of misunderstanding.
There are two edge cases worth noting:
- If a function must replace the entire slice or map (assign a new slice header or a new map value), it needs a pointer to the variable or must return the new value. The
appendfunction returns a new slice for this reason. - Slices of pointers vs. slices of values is a separate decision. A
[]*LargeStructavoids copying when you resize or reorder the slice, but adds indirection and more heap allocations. Start with[]LargeStructand profile before switching.
A Decision Framework
When you stand in front of a function signature or a struct field, run through these questions. They are ordered from the most decisive to the least:
- Must the function modify the original? → Pointer. No way around it.
- Is the type one of the small built-in types (int, float64, string, bool)? → Value.
- Do you need to distinguish “not present” from a zero value? → Pointer.
- Will this type have methods that need a pointer receiver? → Use a pointer receiver consistently.
- Is the struct large enough that copying feels expensive? → Lean toward a pointer, but only after you've measured. Many structs are smaller than you think.
- None of the above apply? → Value. It is the safer, simpler default.
This framework handles the vast majority of decisions. When you have a concrete performance problem, profile it. The compiler and the runtime are smarter than intuition.
Summary
The pointer-versus-value decision is really a decision about ownership and change. Values give you a private copy that no one else can touch — simple, safe, and often faster than you expect because of stack allocation. Pointers give you shared access to the real thing, enabling mutation, optionality, and efficient handling of large data, at the cost of potential nil panics and heap allocation overhead.
The single most important insight is this: default to values, and introduce pointers only when the code demands them. Go's ecosystem, from the standard library down, follows this pattern. When you start with values, you write functions that are easy to test, easy to reason about, and easy to refactor later. When you later add a pointer because you genuinely need mutation or nil semantics, that pointer becomes a deliberate signal to every reader that something important is happening with that data.