When to Use Pointers vs Values in Go

Use values for small, immutable types and pointers for large structs or when modifying the original data is required.

The copy that bites back

You are writing a function to update a user's profile. You pass the user struct to the function, change the email address, and return. Back in main, you print the user, and the email is unchanged. You spent twenty minutes debugging, only to realize the function modified a copy. Or you are passing a configuration struct with fifty fields through a chain of calls. Your profiler shows the CPU burning cycles copying bytes instead of doing work. Latency spikes. Memory usage climbs.

Go gives you two choices for every parameter and return value: pass by value or pass by pointer. Picking the wrong one causes silent logic bugs or performance cliffs. The language does not force a single style. It forces you to think about what you are moving.

Values and pointers are both values

Go passes everything by value. This is the golden rule. When you call a function, Go copies the argument. If the argument is an int, it copies the number. If the argument is a pointer, it copies the address. The copy is always a value. Pointers do not break this rule. They just change what gets copied.

A value is a photocopy of the data. A pointer is a photocopy of the address. If you write on the photocopy, the original is safe. If you follow the address on the photocopy, you can change the original. This distinction trips up developers coming from languages with reference parameters. In Go, there are no reference parameters. There are only values, and some values happen to be addresses.

Minimal example

Here is the difference in action: a value copy leaves the original alone, while a pointer reaches back to modify the data.

package main

import "fmt"

type Counter struct {
    Count int
}

// IncrementValue receives a copy of the struct.
// Changes to c.Count affect only the local copy.
func IncrementValue(c Counter) {
    c.Count++
}

// IncrementPointer receives a copy of the address.
// Dereferencing c modifies the struct at that address.
func IncrementPointer(c *Counter) {
    c.Count++
}

func main() {
    c := Counter{Count: 10}

    IncrementValue(c)
    fmt.Println(c.Count) // prints: 10

    IncrementPointer(&c)
    fmt.Println(c.Count) // prints: 11
}

The ampersand & takes the address of a variable. The star * in a type declaration means "pointer to". The star in an expression dereferences the pointer. IncrementPointer(&c) passes the address of c. Inside the function, c is a pointer. c.Count++ follows the pointer and increments the original field.

What happens under the hood

When you pass a value, the data lands in the function's stack frame. If the struct is small, the compiler might keep it in CPU registers. The function works on that local storage. When the function returns, the stack frame is discarded. Any changes vanish.

When you pass a pointer, the address lands in the stack frame. The data stays where it was, usually on the heap or in the caller's stack frame. The function reads the address, calculates the memory location, and modifies the data there. When the function returns, the address copy is discarded, but the data persists.

Pointers are also copied. When you pass *T, Go copies the pointer value. You cannot reassign the caller's pointer variable from inside the function. If you assign to the parameter, you only change the local copy of the address. The caller's pointer stays the same. To reassign the caller's pointer, you need a pointer to a pointer, **T. This is rare but necessary when a function must update the caller's reference, such as in a linked list insertion that might change the head.

Method receivers and the auto-dereference trick

Methods in Go are just functions with a receiver. The receiver can be a value or a pointer. This choice determines whether the method gets a copy or access to the original.

type Buffer struct {
    data []byte
}

// Write modifies the buffer, so the receiver must be a pointer.
// The receiver name is short, matching the type convention.
func (b *Buffer) Write(p []byte) {
    b.data = append(b.data, p...)
}

// Len reads the buffer. A value receiver is safe and efficient.
// Go copies the struct to call this method.
func (b Buffer) Len() int {
    return len(b.data)
}

Convention aside: receiver names are usually one or two letters matching the type. Use (b *Buffer), not (self *Buffer) or (this *Buffer). The type name is already visible in the signature. Extra words add noise.

Go provides a convenience called auto-dereference. You can call a pointer method on a value, and a value method on a pointer, as long as the value is addressable. If b is a Buffer, you can call b.Write() even though Write expects *Buffer. Go automatically takes the address. If b is a *Buffer, you can call b.Len() even though Len expects Buffer. Go automatically dereferences. This reduces boilerplate without hiding the semantics. The method signature still dictates whether mutation is possible.

Performance: cache locality matters

Pointers cost more than just allocation. They hurt cache locality. When you pass a value, the data is right there in the register or on the stack. When you pass a pointer, the CPU has to fetch the address, then fetch the data at that address. If the data is scattered across memory, the CPU cache misses, and performance tanks.

A slice of structs is a contiguous block of memory. Iterating over it streams data into the cache efficiently. A slice of pointers is a block of addresses pointing to random locations. Iterating over it causes cache thrashing. The CPU spends time waiting for memory instead of processing data.

For small structs, passing by value is often faster than passing by pointer, even if the struct is larger than a pointer. The copy is cheap, and the data is local. For large structs, the copy cost outweighs the cache benefit, so pointers win. The threshold depends on the architecture and the workload, but a good rule of thumb is to benchmark. If the struct fits in a few cache lines, values are likely faster. If it spills over, pointers are safer.

Pitfalls and compiler errors

Nil pointer dereference is the most common runtime panic. If a pointer is nil, dereferencing it crashes the program. The runtime stops with panic: runtime error: invalid memory address or nil pointer dereference. Always check for nil before using a pointer, or ensure the pointer is always initialized.

The compiler catches some mistakes. If you try to take the address of an unaddressable value, it rejects the code. You cannot take the address of a literal or a map key. The compiler complains with cannot take the address of literal or cannot take the address of map index expression. This prevents dangling pointers to temporary data.

Another trap is modifying a value receiver. If you define a method with a value receiver and try to assign to a field, the compiler stops you. You get cannot assign to struct field ... in .... This error saves you from silent bugs where you thought you were updating the original but were only updating the copy.

Convention aside: do not pass *string. Strings are immutable and cheap to pass by value. They are just a pointer and a length under the hood. Passing a pointer to a string adds indirection without benefit. It also forces the string to be allocated on the heap if you need to modify it, which hurts performance. Pass strings by value. If you need a mutable text buffer, use []byte or strings.Builder.

Decision matrix

Use a value when the type is small and immutable, like int, bool, or string.

Use a value when you want to guarantee the caller cannot modify the data.

Use a value when the struct contains no pointers and is small enough that copying is cheaper than allocation overhead.

Use a value when iterating over a collection and cache locality matters.

Use a pointer when you need to modify the original data inside the function.

Use a pointer when the struct is large and copying it would hurt performance.

Use a pointer when the type contains a mutex or other synchronization primitive.

Use a pointer when you need to represent a null state with nil.

Use a pointer when the type implements an interface and you need to satisfy the method set with a pointer receiver.

Where to go next