The flag that never flipped
You write a function to toggle a debug flag in a configuration struct. You pass the struct, flip the boolean, and return. Back in the calling code, the flag is still off. You check the function logic. The assignment is there. The code runs without errors. Yet the state didn't change.
This is Go's default behavior. Go copies data. To share state, you must pass a pointer.
Value semantics and the copy tax
Go uses value semantics. When you pass a variable to a function, the runtime creates a copy of that data. The function receives the copy. The caller keeps the original. If the function modifies the copy, the original stays untouched.
This design makes code predictable. You don't need to scan a function's source to check if it secretly mutates your data. If the parameter is a value, the data is safe.
Pointers change the rules. A pointer holds a memory address. It points to where the actual data lives. When you pass a pointer, you are passing the address. The function can follow the address and modify the data at that location. Everyone looking at that address sees the updates.
Think of a value like a photocopy of a document. If you hand the copy to a friend and they write notes in the margins, your original document stays clean. The friend has their own independent version. A pointer is like writing the address of the document on a slip of paper. You hand the slip to your friend. They go to that address, find the original document, and make changes. Everyone looking at that address sees the updates.
Minimal example
Here's the simplest comparison: pass an integer by value, then pass it by pointer.
package main
import "fmt"
// updateValue takes an int by value. It receives a copy of the bits.
func updateValue(n int) {
n = 100 // modifies the local copy. The caller's variable is unaffected.
}
// updatePointer takes an int by pointer. It receives the memory address.
func updatePointer(n *int) {
*n = 100 // dereferences the pointer to change the data at the address.
}
func main() {
x := 10
updateValue(x)
fmt.Println(x) // prints 10. The copy was changed, not x.
y := 20
updatePointer(&y) // &y gets the address of y
fmt.Println(y) // prints 100. The original memory was modified.
}
Walk through the memory
When you pass x to updateValue, the runtime allocates space on the stack for a new integer and copies the bits from x into that space. The function works on the copy. When the function returns, the copy vanishes. The original x in main never moved.
When you pass &y to updatePointer, you are passing the memory address. The & operator is the address-of operator. It produces a pointer to y. The function receives a pointer. The pointer itself is small. On a 64-bit machine, a pointer is always 8 bytes. The function uses the * operator to dereference the pointer. It follows the address and modifies the data at that location. The data stays alive because y in main still holds the reference.
Go handles the stack and heap allocation automatically. You don't need to worry about whether &y forces the data onto the heap. The compiler performs escape analysis. If the data can stay on the stack, it stays on the stack. If it needs to live longer than the function call, the compiler moves it to the heap. You just use the pointer.
Realistic example: Config mutation
In real code, you use pointers to mutate structs. You also use pointers to avoid copying large structs. Here's a configuration struct with methods.
package main
import "fmt"
type Config struct {
Debug bool
MaxConn int
}
// EnableDebug modifies the struct in place.
// Pointer receiver allows mutation of the caller's data.
func (c *Config) EnableDebug() {
c.Debug = true
}
// GetMaxConn reads the struct.
// Value receiver is fine here since we only need to read.
// The struct is small, so the copy cost is negligible.
func (c Config) GetMaxConn() int {
return c.MaxConn
}
func main() {
cfg := Config{Debug: false, MaxConn: 10}
fmt.Println(cfg.Debug) // prints false
cfg.EnableDebug()
fmt.Println(cfg.Debug) // prints true. The method mutated the original struct.
// Value receiver copies the struct.
// This is safe and fast for small structs.
fmt.Println(cfg.GetMaxConn()) // prints 10
}
The receiver name is usually one or two letters matching the type. (c *Config) is idiomatic. (this *Config) or (self *Config) is not. The community expects short receiver names.
The interface compatibility trap
Pointers and values behave differently with interfaces. This is where Go developers get burned.
If a method has a pointer receiver, only a pointer to the type satisfies the interface. A value does not.
package main
import "fmt"
type Reader interface {
Read() string
}
type File struct {
Name string
}
// Read has a pointer receiver.
// Only *File implements Reader.
func (f *File) Read() string {
return f.Name
}
func process(r Reader) {
fmt.Println(r.Read())
}
func main() {
f := File{Name: "data.txt"}
// This works. We pass a pointer.
process(&f)
// This fails to compile.
// process(f) // cannot use f (variable of type File) as Reader in argument: File does not implement Reader (Read has pointer receiver)
}
The compiler rejects the value with File does not implement Reader (Read has pointer receiver). The error message is explicit. The interface requires a method that can modify the receiver or access the receiver's address. A value cannot guarantee that.
If you define the method with a value receiver, both values and pointers satisfy the interface. Go automatically takes the address of the value when needed.
// Read has a value receiver.
// Both File and *File implement Reader.
func (f File) Read() string {
return f.Name
}
Accept interfaces, return structs. This mantra applies to pointers too. Functions should accept interface types to allow flexibility. They should return concrete structs or pointers to structs. Don't return interfaces.
Slices, maps, and the pointer illusion
You rarely need pointers to slices or maps. Slices and maps are already reference types.
A slice is a descriptor containing a pointer to an underlying array, a length, and a capacity. When you pass a slice by value, Go copies the descriptor. The descriptor is 24 bytes. The copy is cheap. The underlying array is shared. If you modify the elements of the slice, the caller sees the changes.
package main
import "fmt"
// updateSlice modifies the elements of the slice.
// The slice header is copied, but the underlying array is shared.
func updateSlice(s []int) {
s[0] = 99 // modifies the shared array
}
func main() {
nums := []int{1, 2, 3}
updateSlice(nums)
fmt.Println(nums) // prints [99 2 3]. The change is visible.
}
You don't need *[]int. Passing a pointer to a slice adds indirection without benefit. The slice header is already small.
Maps work the same way. A map variable holds a pointer to the map data structure. Passing a map by value copies the pointer. The map data is shared. You don't need *map[string]int.
Don't pass a *string either. Strings are immutable and cheap to pass by value. A string is a pointer to bytes plus a length. Copying the string header is fast. Passing a pointer to a string just adds an extra level of indirection.
Pitfalls and compiler errors
Pointers introduce new failure modes. Values are always valid. Pointers can be nil.
If you declare a pointer without initializing it, it is nil. Dereferencing a nil pointer panics.
package main
import "fmt"
func main() {
var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereference
}
The runtime stops the program with panic: runtime error: invalid memory address or nil pointer dereference. Always check pointers for nil before dereferencing, or ensure they are initialized.
You cannot take the address of a map value. Map values are not stored at fixed addresses. The map implementation may move values around during growth or rehashing.
package main
import "fmt"
func main() {
m := map[string]int{"a": 1}
// v := &m["a"] // cannot take the address of map value
fmt.Println(m["a"])
}
The compiler rejects this with cannot take the address of map value. If you need to modify a map value, update the map directly. m["a"] = 2.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Pointers don't cause leaks, but shared state via pointers can hide bugs. If a goroutine holds a pointer to a buffer and waits for a signal that never arrives, the goroutine never exits. The buffer stays in memory. The goroutine stays in memory. Always have a cancellation path. Use context.Context to signal shutdown. Context is plumbing. Run it through every long-lived call site.
Decision matrix
Go favors simplicity. Values are simpler. Pointers add indirection. Use the simplest thing that works.
Use a value when the data is small and you don't need to modify the original. Use a value when you want to ensure the function cannot accidentally change the caller's data. Use a value when the type is immutable, like a string or an integer. Use a value when you are returning data from a function and the caller should own a copy.
Use a pointer when you need to mutate the data inside a function or method. Use a pointer when the struct is large and copying it is expensive. A common rule of thumb is to use a pointer for structs larger than 64 bytes. Use a pointer when you need to satisfy an interface that requires a pointer receiver. Use a pointer when you need to represent the absence of a value, using nil as a flag.
Don't use a pointer to a slice. Don't use a pointer to a map. Don't use a pointer to a string. These types are already cheap to pass and share data by design.
Where to go next
- How to Declare and Use Pointers in Go
- Why You Can't Take the Address of a Map Value in Go
- How Memory Management Works in Go
Values are safe. Pointers are powerful. Pick the tool that matches the job.