The copy problem
You have a User struct representing a player in a game. You write a function to level up the player. You pass the player to the function, the function increments the level, but when you check the player back in main, the level hasn't changed. The function modified a copy. The original player is still stuck at level 1.
This happens because Go passes arguments by value. Every time you pass a struct to a function, the compiler copies the entire struct. If the struct is small, the copy is fast. If the struct is large, the copy wastes time and memory. More importantly, the copy is isolated. Changes to the copy never affect the original.
To fix this, you pass a pointer to the struct. A pointer holds the memory address of the struct, not the data itself. The function receives the address, finds the struct in memory, and modifies it directly. The original data changes. No copy happens.
Pointers are addresses, not data
A struct pointer stores where a struct lives in memory. The * symbol declares a pointer type. The & operator takes the address of a value.
Think of a struct as a house and a pointer as the street address. If you hand someone a copy of the house, they can renovate their copy, but your house stays the same. If you hand them the address, they go to your house and renovate it. Everyone sees the changes.
In Go, pointers have a zero value of nil. A nil pointer points to nothing. Accessing a field on a nil pointer causes a panic. A struct has a zero value where all fields are zeroed. Accessing a field on a zero-value struct returns the field's zero value. This distinction matters. A nil pointer is an error state. A zero-value struct is a valid, empty object.
Minimal pointer workflow
Here's the simplest pointer workflow: create a struct, take its address, modify through the pointer, and see the change reflected in the original.
package main
type Player struct {
Name string
Level int
}
func main() {
// Create a Player on the stack.
p := Player{Name: "Alice", Level: 1}
// Take the address of p. ptr points to the same memory as p.
ptr := &p
// Go auto-dereferences pointers for field access.
// You use . just like with a value.
// The compiler inserts (*ptr).Level behind the scenes.
ptr.Level = 2
// p and ptr share the same data.
// Changing ptr.Level changed p.Level.
println(p.Level) // prints 2
}
Go provides auto-dereferencing to keep syntax clean. You never need to write (*ptr).Field. The compiler handles the indirection automatically. This makes pointer code read almost like value code, even though the memory behavior is different.
Methods and receivers
Pointers shine in method receivers. A method receiver determines whether the method operates on a copy or the original. If a method needs to modify the struct, the receiver must be a pointer. If you use a value receiver, the method gets a copy, and modifications vanish when the method returns.
Here's how pointers work with methods: use a pointer receiver when the method mutates the struct, and let Go handle the address-taking automatically.
package main
import "fmt"
type Counter struct {
Count int
}
// Increment modifies the struct.
// The pointer receiver (*Counter) allows changes to persist.
func (c *Counter) Increment() {
c.Count++
}
// Read returns a value.
// A value receiver is fine here since we aren't modifying c.
// The compiler copies c, but we only read the data.
func (c Counter) Read() int {
return c.Count
}
func main() {
// Create a counter.
c := Counter{Count: 0}
// Call method on the value.
// Go automatically takes the address for pointer receivers.
// You don't need to write (&c).Increment().
c.Increment()
// The change persists.
fmt.Println(c.Count) // prints 1
}
Go also auto-addresses when calling methods. If you have a value and call a method with a pointer receiver, the compiler takes the address for you. If you have a pointer and call a method with a value receiver, the compiler dereferences the pointer. You can call either type of method on either a value or a pointer, as long as the value is addressable.
Convention aside: receiver names are usually one or two letters matching the type. Write (c *Counter), not (this *Counter) or (self *Counter). The community expects short names. It keeps the signature readable.
Aliasing and shared state
When you assign a pointer to another variable, you copy the address, not the struct. Two pointers can point to the same struct. This is called aliasing. It's powerful for sharing state, but it can lead to bugs if you forget that multiple variables reference the same data.
Here's how aliasing works: assign a pointer to a new variable, modify through either pointer, and watch both reflect the change.
package main
type Server struct {
Port int
}
func main() {
// Create a server.
s := Server{Port: 80}
// p1 points to s.
p1 := &s
// p2 points to the same memory as p1.
// No copy of the struct happens here.
// p1 and p2 are aliases.
p2 := p1
// Modify through p2.
p2.Port = 443
// s and p1 see the change.
// All three variables share the same underlying data.
println(s.Port) // prints 443
}
Aliasing is common in pipelines and concurrent code. A goroutine might hold a pointer to a config struct. Another goroutine updates the config. Both see the latest data. The risk is that you might update the struct unexpectedly from a different part of the code. Always document which pointers share state. If a struct is shared, consider using a mutex or context to coordinate access.
Pitfalls and compiler errors
The biggest risk with pointers is the nil dereference. If a pointer is nil, it points to nothing. Accessing a field on a nil pointer causes a runtime panic. The runtime panics with invalid memory address or nil pointer dereference if you touch a nil pointer.
You can avoid this by checking for nil before use, or by ensuring pointers are always initialized. If a function returns a pointer, it might return nil to indicate an error or empty state. The caller must check.
package main
type Config struct {
Port int
}
func main() {
// Declare a pointer without initializing it.
// It defaults to nil.
var cfg *Config
// This panics. cfg is nil.
// cfg.Port = 8080
}
The compiler catches some pointer mistakes at build time. If you try to assign a value to a pointer variable, the compiler rejects it with cannot use value (type Struct) as type *Struct in assignment. If you try to take the address of a non-addressable value, like a map index or a function return, the compiler complains with cannot take the address of....
Another gotcha involves interfaces. If an interface requires a method with a pointer receiver, you must pass a pointer to the interface. A value won't satisfy the interface. The compiler rejects this with cannot use s (type Server) as type Interface in argument: Server does not implement Interface (method Method has pointer receiver). This happens because the value type doesn't have the pointer receiver method in its method set.
Convention aside: new(T) returns a *T with zero values. It's rarely used in modern Go because &T{} is more readable and allows initialization. Use &T{}. It makes the intent clear and lets you set fields in one expression.
Pointers vs reference types
Slices, maps, and channels are reference types. They contain a pointer internally. Passing a map by value copies the map header, but the header points to the same underlying data. You can modify a map through a value parameter. You don't need a pointer to a map.
Using a pointer to a map adds indirection without benefit. It makes the code harder to read and doesn't improve performance. The map header is small. Copying it is cheap. The underlying data is shared regardless.
package main
import "fmt"
func updateScore(m map[string]int, name string) {
// m is a copy of the map header.
// The header points to the same underlying array.
// Changes to values persist.
m[name]++
}
func main() {
scores := map[string]int{"Alice": 10}
updateScore(scores, "Alice")
fmt.Println(scores["Alice"]) // prints 11
}
The same rule applies to slices and channels. Pass them by value. The compiler copies the header, and the data is shared. Reserve pointers for structs and large values where you need mutation or want to avoid copying bytes.
Escape analysis and allocation
Pointers don't force heap allocation. Go's compiler performs escape analysis to determine where variables live. If a pointer doesn't escape the function, the compiler keeps the struct on the stack. You get pointer semantics without the cost of heap allocation.
If the pointer escapes, the compiler moves the struct to the heap. This happens when you return a pointer, store it in a global variable, or pass it to a goroutine. Heap allocation is slightly slower and triggers garbage collection. Stack allocation is fast and automatic.
You don't need to manage memory manually. The compiler decides. Focus on correctness. Use pointers when you need mutation or want to avoid large copies. The compiler optimizes the rest.
When to use pointers
Use a struct pointer when the function or method needs to modify the original struct. Use a struct pointer when the struct is large and you want to avoid copying bytes on every call. Use a struct value when the struct is small and you only need to read the data. Use a struct value when you want to guarantee that a function cannot mutate the caller's state. Use a pointer when the interface requires a pointer receiver method. Use a value when the interface is satisfied by a value receiver. Use a pointer to a pointer only when you need to update the pointer itself, such as in a linked list node insertion.
Pointers are addresses. Values are copies. Nil checks save panics. Auto-dereferencing keeps syntax clean. Pointer receivers mutate. Value receivers copy. Don't overuse pointers for tiny structs. Trust the compiler's escape analysis.